Refactor of the main.py in multiple files. 0.99.65

parent 05082603
...@@ -54,7 +54,7 @@ from .auth.qwen import QwenOAuth2 ...@@ -54,7 +54,7 @@ from .auth.qwen import QwenOAuth2
from .handlers import RequestHandler, RotationHandler, AutoselectHandler from .handlers import RequestHandler, RotationHandler, AutoselectHandler
from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model
__version__ = "0.99.64" __version__ = "0.99.65"
__all__ = [ __all__ = [
# Config # Config
"config", "config",
......
"""
All ASGI middleware functions extracted from main.py.
"""
import time
import logging
import threading
from typing import Optional
from fastapi import Request
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Client rate limiter state
# ---------------------------------------------------------------------------
_client_rl_state: dict = {}
_client_rl_lock = threading.Lock()
def _get_real_client_ip(request: Request) -> str:
xff = request.headers.get('X-Forwarded-For', '')
if xff:
return xff.split(',')[0].strip()
client = request.scope.get('client')
return client[0] if client else 'unknown'
def _client_rl_key(request: Request, category: str) -> str:
if category == 'api':
auth_hdr = request.headers.get('Authorization', '')
if auth_hdr.startswith('Bearer '):
token = auth_hdr[7:].strip()
if token:
return f"token:{token}"
return f"ip:{_get_real_client_ip(request)}"
def _client_rl_check(bucket: str, window_seconds: int, max_requests: int) -> tuple:
if max_requests <= 0:
return True, 0
now = time.time()
cutoff = now - window_seconds
with _client_rl_lock:
ts = [t for t in _client_rl_state.get(bucket, []) if t > cutoff]
if len(ts) >= max_requests:
retry_after = int(ts[0] + window_seconds - now) + 1
_client_rl_state[bucket] = ts
return False, retry_after
ts.append(now)
_client_rl_state[bucket] = ts
return True, 0
# ---------------------------------------------------------------------------
# Geo-blocking helpers
# ---------------------------------------------------------------------------
_LOCAL_IPS = {"127.0.0.1", "::1", "localhost"}
_BLOCK_MESSAGE = "We do not support the Israeli genocide of Palestinian people."
def _get_client_ip(request: Request) -> Optional[str]:
xff = request.headers.get("X-Forwarded-For")
if xff:
return xff.split(",")[0].strip()
client = request.scope.get("client")
return client[0] if client else None
def _is_local_client(request: Request) -> bool:
xff = request.headers.get("X-Forwarded-For")
if xff:
return xff.split(",")[0].strip() in _LOCAL_IPS
forwarded_host = request.headers.get("X-Forwarded-Host") or request.headers.get("X-Real-IP")
if forwarded_host:
host = forwarded_host.split(":")[0].strip()
if host not in _LOCAL_IPS:
return False
ip = _get_client_ip(request)
return ip in _LOCAL_IPS if ip else False
class GenocidalBlockingMiddleware(BaseHTTPMiddleware):
"""Block Israeli IPs/domains."""
def __init__(self, app, server_ip_blocked_ref):
super().__init__(app)
self._server_ip_blocked_ref = server_ip_blocked_ref
async def dispatch(self, request: Request, call_next):
if request.url.path == "/dashboard/blocked":
return await call_next(request)
if await self._should_block(request):
return self._block_response(request)
return await call_next(request)
async def _should_block(self, request: Request) -> bool:
if self._server_ip_blocked_ref():
return True
host = request.headers.get("host", "").lower().split(":")[0]
if host.endswith(".il"):
return True
from aisbf import geolocation
client_ip = _get_client_ip(request)
if client_ip:
country = await geolocation.get_ip_country(client_ip)
if country == 'IL':
return True
return False
def _block_response(self, request: Request):
from aisbf.app.templates import get_base_url
path = request.url.path
if path.startswith("/api") or path.startswith("/mcp"):
return JSONResponse(status_code=403, content={"error": _BLOCK_MESSAGE})
return RedirectResponse(url=f"{get_base_url(request)}/dashboard/blocked", status_code=302)
# ---------------------------------------------------------------------------
# Middleware factory functions (to be registered on the FastAPI app)
# ---------------------------------------------------------------------------
def make_api_token_authorization_middleware(get_server_config, get_db):
async def api_token_authorization_middleware(request: Request, call_next):
path = request.url.path
server_config = get_server_config()
if (path == "/" or path.startswith("/dashboard") or path.startswith("/auth/") or
path.startswith("/api/admin") or path.startswith("/api/webhooks/") or
path == "/favicon.ico" or path.startswith("/.well-known/")):
return await call_next(request)
if request.method == "GET" and path in ["/api/models", "/api/v1/models"]:
return await call_next(request)
if not (server_config and server_config.get('auth_enabled', False)):
return await call_next(request)
is_global_token = getattr(request.state, 'is_global_token', False)
user_id = getattr(request.state, 'user_id', None)
if (path.startswith("/api/u/") or path.startswith("/mcp/u/") or
path.startswith("/api/v1/u/") or path.startswith("/mcp/v1/u/")):
if is_global_token:
return JSONResponse(status_code=403, content={"error": "Global tokens cannot access user-specific endpoints."})
path_parts = path.split('/')
if len(path_parts) >= 4 and path_parts[2] == 'u':
target_username = path_parts[3]
if not user_id:
return JSONResponse(status_code=401, content={"error": "Authentication required."})
db = get_db()
authenticated_user = db.get_user_by_id(user_id)
if not authenticated_user:
return JSONResponse(status_code=403, content={"error": "Invalid user token."})
if authenticated_user['username'] != target_username:
return JSONResponse(status_code=403, content={"error": "You can only access your own user-specific endpoints."})
token_scope = getattr(request.state, 'token_scope', 'both')
is_mcp_path = path.startswith("/mcp/u/") or path.startswith("/mcp/v1/u/")
if is_mcp_path and token_scope == 'api':
return JSONResponse(status_code=403, content={"error": "This token does not have MCP access."})
if not is_mcp_path and token_scope == 'mcp':
return JSONResponse(status_code=403, content={"error": "This token does not have API access."})
else:
if not is_global_token:
return JSONResponse(status_code=403, content={"error": "User tokens cannot access global endpoints."})
return await call_next(request)
return api_token_authorization_middleware
def make_auth_middleware(get_server_config, get_config, get_db, url_for_fn):
async def auth_middleware(request: Request, call_next):
server_config = get_server_config()
config = get_config()
if server_config and server_config.get('auth_enabled', False):
if (request.url.path == "/" or request.url.path.startswith("/dashboard") or
request.url.path.startswith("/auth/") or
request.url.path.startswith("/api/webhooks/") or
request.url.path == "/favicon.ico" or
request.url.path.startswith("/.well-known/")):
return await call_next(request)
if request.url.path.startswith("/api/admin"):
expires_at = request.session.get('expires_at')
if (request.session.get('logged_in') and request.session.get('role') == 'admin' and
not (expires_at and int(time.time()) > expires_at)):
return await call_next(request)
if request.method == "GET" and request.url.path in ["/api/models", "/api/v1/models"]:
return await call_next(request)
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return JSONResponse(status_code=401, content={"error": "Missing or invalid Authorization header."})
token = auth_header.replace('Bearer ', '')
allowed_tokens = server_config.get('auth_tokens', [])
if token in allowed_tokens:
request.state.user_id = None
request.state.token_id = None
request.state.is_global_token = True
request.state.token_scope = 'api'
request.state.is_admin = True
else:
db = get_db()
user_auth = db.authenticate_user_token(token)
if user_auth:
request.state.user_id = user_auth['user_id']
request.state.token_id = user_auth['token_id']
request.state.is_global_token = False
request.state.token_scope = user_auth.get('scope', 'api')
request.state.is_admin = (user_auth.get('role') == 'admin')
else:
return JSONResponse(status_code=403, content={"error": "Invalid authentication token"})
else:
request.state.user_id = None
request.state.token_id = None
request.state.is_global_token = False
request.state.token_scope = 'both'
if (request.url.path.startswith("/dashboard") and
request.session.get('logged_in') and
request.session.get('role') != 'admin'):
require_verification = False
if config and hasattr(config, 'aisbf') and hasattr(config.aisbf, 'signup'):
require_verification = getattr(config.aisbf.signup, 'require_email_verification', False)
user_id = request.session.get('user_id')
if user_id and require_verification:
try:
db = get_db()
current_user = db.get_user_by_id(user_id)
if current_user and current_user.get('email_verified') != request.session.get('email_verified'):
request.session.clear()
return RedirectResponse(url=url_for_fn(request, "/dashboard/login") + "?error=Session+expired", status_code=303)
except Exception:
pass
if user_id:
try:
db = get_db()
if not db.get_user_by_id(user_id):
request.session.clear()
return RedirectResponse(url=url_for_fn(request, "/dashboard/login") + "?error=Account+deleted", status_code=303)
except Exception:
pass
if require_verification and not request.session.get('email_verified'):
allowed_routes = ["/dashboard/verify", "/dashboard/resend-verification",
"/dashboard/logout", "/dashboard/verify-email"]
if not any(request.url.path == r or request.url.path == r + "/" for r in allowed_routes):
return RedirectResponse(url=url_for_fn(request, "/dashboard/verify"), status_code=303)
return await call_next(request)
return auth_middleware
def make_tier_limit_middleware(get_db, background_tasks_ref):
async def tier_limit_middleware(request: Request, call_next):
import asyncio as _asyncio
if (request.url.path == "/" or request.url.path.startswith("/dashboard") or
request.url.path == "/favicon.ico" or
request.url.path.startswith("/.well-known/") or
request.url.path.startswith("/mcp") or
request.url.path.startswith("/auth/")):
return await call_next(request)
if request.method == "GET" and (request.url.path.endswith("/models") or request.url.path.endswith("/models/")):
return await call_next(request)
user_id = getattr(request.state, 'user_id', None)
if not user_id:
return await call_next(request)
db = get_db()
tier = db.get_user_tier(user_id)
if not tier:
return await call_next(request)
subscription = db.get_user_subscription(user_id)
if subscription:
from datetime import datetime
if subscription['expires_at'] and datetime.fromisoformat(subscription['expires_at']) < datetime.now():
return JSONResponse(status_code=402, content={"error": "Subscription expired", "code": "subscription_expired"})
usage = db.get_user_usage(user_id)
def _check_limit(limit_val, current_val, label, code):
if limit_val == 0:
return JSONResponse(status_code=402, content={"error": f"{label} not permitted", "code": f"{code}_blocked"})
if limit_val > 0 and current_val >= limit_val:
return JSONResponse(status_code=429, content={"error": f"{label} limit exceeded", "limit": limit_val, "current": current_val, "code": f"{code}_exceeded"})
return None
r = _check_limit(tier['max_requests_per_day'], usage['requests_today'], "Daily request", "daily_limit")
if r:
return r
r = _check_limit(tier['max_requests_per_month'], usage['requests_month'], "Monthly request", "monthly_limit")
if r:
return r
response = await call_next(request)
if request.method == "POST" and any(request.url.path.endswith(ep) for ep in (
"/chat/completions", "/completions", "/embeddings",
"/audio/transcriptions", "/audio/speech", "/images/generations"
)):
_t = _asyncio.create_task(db.increment_user_request_count(user_id))
background_tasks_ref.add(_t)
_t.add_done_callback(background_tasks_ref.discard)
return response
return tier_limit_middleware
def make_dashboard_context_middleware():
async def dashboard_context_middleware(request: Request, call_next):
if request.url.path.startswith("/dashboard") and 'session' in request.scope:
is_cloud = (request.url.hostname == 'aisbf.cloud' or
request.url.hostname.endswith('.aisbf.cloud'))
is_onion = request.url.hostname == 'aisbfity4ud6nsht53tsh2iauaur2e4dah2gplcprnikyjpkg72vfjad.onion'
request.state.is_aisbf_cloud = is_cloud or is_onion
if request.session.get('logged_in', False):
request.state.welcome_shown = request.session.get('welcome_shown', False)
else:
request.state.welcome_shown = True
return await call_next(request)
return dashboard_context_middleware
def make_client_rate_limiting_middleware(get_config):
async def client_rate_limiting_middleware(request: Request, call_next):
path = request.url.path
if path in ('/health', '/favicon.ico') or path.startswith('/static/'):
return await call_next(request)
config = get_config()
aisbf_conf = config.get_aisbf_config() if config else None
rl_cfg = getattr(aisbf_conf, 'client_rate_limiting', None) if aisbf_conf else None
if not rl_cfg or not rl_cfg.enabled:
return await call_next(request)
is_api_mcp = (path.startswith('/api/') or path.startswith('/mcp/') or
path in ('/v1/chat/completions', '/v1/models', '/api/models', '/api/v1/models'))
category = 'api' if is_api_mcp else 'general'
limit_cfg = rl_cfg.api if is_api_mcp else rl_cfg.general
client_key = _client_rl_key(request, category)
allowed, retry_after = _client_rl_check(f"{client_key}:{category}:min", 60, limit_cfg.requests_per_minute)
if not allowed:
return JSONResponse(status_code=429, content={"error": "Too many requests", "retry_after": retry_after},
headers={"Retry-After": str(retry_after)})
allowed, retry_after = _client_rl_check(f"{client_key}:{category}:hour", 3600, limit_cfg.requests_per_hour)
if not allowed:
return JSONResponse(status_code=429, content={"error": "Too many requests", "retry_after": retry_after},
headers={"Retry-After": str(retry_after)})
return await call_next(request)
return client_rate_limiting_middleware
"""
Provider model fetching, caching, and background refresh.
Extracted from main.py.
"""
import time
import logging
import asyncio
from typing import Optional
logger = logging.getLogger(__name__)
_model_cache: dict = {}
_model_cache_timestamps: dict = {}
_cache_refresh_interval = 24 * 3600
_endpoint_model_cache: dict = {}
_background_tasks: set = set()
async def fetch_provider_models(provider_id: str, config, user_id: Optional[int] = None) -> list:
global _model_cache, _model_cache_timestamps, _endpoint_model_cache
cache_key = f"{provider_id}:{user_id}" if user_id else provider_id
try:
if not user_id and config is not None:
try:
prov_cfg = config.get_provider(provider_id)
prov_type = getattr(prov_cfg, 'type', '')
endpoint = getattr(prov_cfg, 'endpoint', '') or ''
endpoint_key = f"{prov_type}:{endpoint}"
if endpoint_key and endpoint_key in _endpoint_model_cache:
cached_models, cached_at = _endpoint_model_cache[endpoint_key]
if time.time() - cached_at < _cache_refresh_interval:
_model_cache[cache_key] = cached_models
_model_cache_timestamps[cache_key] = cached_at
return cached_models
except Exception:
pass
from aisbf.handlers import RequestHandler
from starlette.requests import Request as StarletteRequest
request_handler = RequestHandler(user_id=user_id)
scope = {"type": "http", "method": "GET", "headers": [],
"query_string": b"", "path": f"/api/{provider_id}/models"}
dummy_request = StarletteRequest(scope)
models = await request_handler.handle_model_list(dummy_request, provider_id)
now = time.time()
_model_cache[cache_key] = models
_model_cache_timestamps[cache_key] = now
if not user_id and config is not None:
try:
prov_cfg = config.get_provider(provider_id)
prov_type = getattr(prov_cfg, 'type', '')
endpoint = getattr(prov_cfg, 'endpoint', '') or ''
endpoint_key = f"{prov_type}:{endpoint}"
if endpoint_key and endpoint_key not in _endpoint_model_cache:
_endpoint_model_cache[endpoint_key] = (models, now)
except Exception:
pass
logger.info(f"Cached {len(models)} models from provider: {provider_id}")
return models
except Exception as e:
logger.error(f"Failed to fetch models from provider {provider_id}: {e}")
return []
async def refresh_model_cache(config):
global _endpoint_model_cache
while True:
try:
await asyncio.sleep(_cache_refresh_interval)
logger.info("Starting periodic model cache refresh...")
_endpoint_model_cache.clear()
for provider_id, provider_config in config.providers.items():
if not (hasattr(provider_config, 'models') and provider_config.models):
await fetch_provider_models(provider_id, config)
logger.info("Model cache refresh complete")
except Exception as e:
logger.error(f"Error in model cache refresh task: {e}")
async def get_provider_models(provider_id: str, provider_config, config, user_id: Optional[int] = None) -> list:
current_time = int(time.time())
try:
from aisbf.providers import get_provider_handler
api_key = getattr(provider_config, 'api_key', None)
get_provider_handler(provider_id, api_key, user_id=user_id)
except Exception as e:
logger.debug(f"Skipping provider {provider_id}: {e}")
return []
if hasattr(provider_config, 'models') and provider_config.models:
return [
{
'id': f"{provider_id}/{model.name}",
'object': 'model',
'created': current_time,
'owned_by': provider_config.name,
'provider': provider_id,
'type': 'provider',
'model_name': model.name,
'context_size': getattr(model, 'context_size', None),
'capabilities': getattr(model, 'capabilities', []),
'description': getattr(model, 'description', None),
'architecture': getattr(model, 'architecture', None),
'pricing': getattr(model, 'pricing', None),
'top_provider': getattr(model, 'top_provider', None),
'supported_parameters': getattr(model, 'supported_parameters', None),
'default_parameters': getattr(model, 'default_parameters', None),
'source': 'local_config'
}
for model in provider_config.models
]
cache_key = f"{provider_id}:{user_id}" if user_id else provider_id
if cache_key in _model_cache:
cache_age = time.time() - _model_cache_timestamps.get(cache_key, 0)
if cache_age < _cache_refresh_interval:
cached_models = _model_cache[cache_key]
if cached_models:
models = []
for model in cached_models:
mc = model.copy()
mc['id'] = f"{provider_id}/{model.get('id', model.get('name', ''))}"
mc.setdefault('object', 'model')
mc.setdefault('created', current_time)
mc.setdefault('owned_by', provider_config.name)
mc['provider'] = provider_id
mc['type'] = 'provider'
mc['source'] = 'api_cache'
models.append(mc)
return models
api_key = getattr(provider_config, 'api_key', None)
api_key_required = getattr(provider_config, 'api_key_required', True)
if not api_key_required or (api_key and not api_key.startswith('YOUR_')):
try:
fetched = await fetch_provider_models(provider_id, config, user_id=user_id)
if fetched:
models = []
for model in fetched:
mc = model.copy()
mc['id'] = f"{provider_id}/{model.get('id', model.get('name', ''))}"
mc.setdefault('object', 'model')
mc.setdefault('created', current_time)
mc.setdefault('owned_by', provider_config.name)
mc['provider'] = provider_id
mc['type'] = 'provider'
mc['source'] = 'api_cache'
models.append(mc)
return models
except Exception as e:
logger.debug(f"Failed to fetch models for provider {provider_id}: {e}")
return []
async def prefetch_global_provider_models(config):
import os
from aisbf.database import DatabaseRegistry
logger.info("=== STARTUP MODEL PRE-FETCHING (background) ===")
prefetch_count = 0
total = 0
for provider_id, provider_config in config.providers.items():
total += 1
if hasattr(provider_config, 'models') and provider_config.models:
continue
provider_type = getattr(provider_config, 'type', '')
if provider_type in ('kilo', 'kilocode'):
has_valid_auth = False
api_key = getattr(provider_config, 'api_key', None)
if api_key and not api_key.startswith('YOUR_'):
has_valid_auth = True
if not has_valid_auth:
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')
if kilo_config.get('api_base'):
api_base = kilo_config['api_base']
oauth2 = KiloOAuth2(credentials_file=credentials_file, api_base=api_base)
if oauth2.is_authenticated():
has_valid_auth = True
except Exception:
pass
if not has_valid_auth:
try:
db = DatabaseRegistry.get_config_database()
if db:
for af in db.get_user_auth_files(0, provider_id):
if af.get('file_type') in ('credentials', 'kilo_credentials', 'config') and os.path.exists(af.get('file_path', '')):
has_valid_auth = True
break
except Exception:
pass
if not has_valid_auth:
logger.info(f"Skipping model prefetch for Kilo provider '{provider_id}' (no valid auth)")
continue
try:
models = await fetch_provider_models(provider_id, config)
if models:
prefetch_count += 1
logger.info(f"✓ Pre-fetched {len(models)} models from provider: {provider_id}")
else:
logger.warning(f"✗ Pre-fetch returned empty model list from provider '{provider_id}'")
except Exception as e:
logger.error(f"✗ Failed to pre-fetch models from provider '{provider_id}': {e}")
logger.info(f"=== MODEL PRE-FETCHING COMPLETE: {prefetch_count}/{total} providers ===")
def _apply_usage_disable(db, user_id, provider_id: str, usage_data: dict):
import time as _time
try:
rl = usage_data.get('rate_limit') if usage_data else None
if not rl:
return
windows = []
if rl.get('primary_window'):
windows.append(rl['primary_window'])
if rl.get('secondary_window'):
windows.append(rl['secondary_window'])
windows.extend(rl.get('additional_rate_limits') or [])
max_reset_at = None
for w in windows:
if w.get('used_percent', 0) >= 100 or rl.get('limit_reached'):
reset_at = w.get('reset_at')
if reset_at and (max_reset_at is None or reset_at > max_reset_at):
max_reset_at = float(reset_at)
if max_reset_at and max_reset_at > _time.time():
db.set_provider_disabled_until(user_id, provider_id, max_reset_at, 'usage_limit')
else:
db.clear_provider_disabled_until(user_id, provider_id)
except Exception as e:
logger.debug(f"_apply_usage_disable error for {provider_id}: {e}")
async def _refresh_provider_usage_if_stale(provider_id: str, user_id):
try:
import datetime as _dt
from aisbf.database import DatabaseRegistry
db = DatabaseRegistry.get_config_database()
cached = db.get_provider_usage(user_id, provider_id)
now = _dt.datetime.utcnow()
if cached:
lu = cached.get('last_updated')
if lu:
if hasattr(lu, 'utcoffset'):
lu = lu.replace(tzinfo=None)
if isinstance(lu, str):
try:
lu = _dt.datetime.fromisoformat(lu)
except Exception:
lu = None
if lu and (now - lu).total_seconds() < 120:
return
from aisbf.providers import get_provider_handler
handler = get_provider_handler(provider_id, user_id=user_id)
if not handler.supports_usage():
return
usage_data = await handler.get_usage()
if usage_data:
db.save_provider_usage(user_id, provider_id, usage_data)
_apply_usage_disable(db, user_id, provider_id, usage_data)
except Exception as e:
logger.debug(f"Background usage refresh failed for {provider_id}: {e}")
"""
App startup/shutdown, initialization, signal handling, and server config loading.
Extracted from main.py.
"""
from decimal import Decimal
from typing import Optional
import time
import logging
import sys
import os
import signal
import atexit
import secrets
import asyncio
import multiprocessing
import threading
import json
from pathlib import Path
from logging.handlers import RotatingFileHandler
from cryptography.fernet import Fernet
# ---------------------------------------------------------------------------
# Globals (shared with main.py via import)
# ---------------------------------------------------------------------------
_custom_config_dir = None
_original_argv = None
payment_service = None
_initialized = False
_server_ip_blocked: bool = False
_claude_cli_mode = False
_user_handlers_cache = {}
tor_service = None
_cache_refresh_task = None
_background_tasks: set = set()
_config_reload_lock = threading.Lock()
def set_config_dir(config_dir: str):
global _custom_config_dir
_custom_config_dir = config_dir
os.environ['AISBF_CONFIG_DIR'] = config_dir
def get_config_dir():
return _custom_config_dir or os.environ.get('AISBF_CONFIG_DIR')
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
class BrokenPipeFilter(logging.Filter):
def filter(self, record):
if record.getMessage().startswith('--- Logging error ---'):
return False
if 'BrokenPipeError' in record.getMessage():
return False
return True
class SafeStderr:
def __init__(self, original_stderr, log_file_path):
self.original_stderr = original_stderr
self.log_file = None
try:
self.log_file = open(log_file_path, 'a')
except Exception:
pass
def write(self, data):
if '--- Logging error ---' in data or 'BrokenPipeError' in data:
return
if self.log_file:
try:
self.log_file.write(data)
self.log_file.flush()
except (BrokenPipeError, OSError):
pass
else:
try:
self.original_stderr.write(data)
except (BrokenPipeError, OSError):
pass
def flush(self):
if self.log_file:
try:
self.log_file.flush()
except (BrokenPipeError, OSError):
pass
else:
try:
self.original_stderr.flush()
except (BrokenPipeError, OSError):
pass
def setup_logging():
if os.geteuid() == 0:
log_dir = Path('/var/log/aisbf')
else:
log_dir = Path.home() / '.local' / 'var' / 'log' / 'aisbf'
log_dir.mkdir(parents=True, exist_ok=True)
AISBF_DEBUG = os.environ.get('AISBF_DEBUG', '').lower() in ('true', '1', 'yes')
log_file = log_dir / 'aisbf.log'
file_handler = RotatingFileHandler(log_file, maxBytes=50*1024*1024, backupCount=5, encoding='utf-8')
file_handler.setLevel(logging.DEBUG)
fmt = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
file_handler.setFormatter(fmt)
error_handler = RotatingFileHandler(log_dir / 'aisbf_error.log', maxBytes=50*1024*1024, backupCount=5, encoding='utf-8')
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(fmt)
console_handler = logging.StreamHandler(sys.stdout)
if AISBF_DEBUG:
console_handler.setLevel(logging.DEBUG)
if not getattr(setup_logging, '_debug_banner_shown', False):
print("=== AISBF DEBUG MODE ENABLED ===")
setup_logging._debug_banner_shown = True
else:
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
root_logger.addHandler(file_handler)
root_logger.addHandler(error_handler)
root_logger.addHandler(console_handler)
bpf = BrokenPipeFilter()
for h in (file_handler, error_handler, console_handler):
h.addFilter(bpf)
try:
sys.stderr = SafeStderr(sys.stderr, log_dir / 'aisbf_stderr.log')
except Exception as e:
logging.getLogger(__name__).warning(f"Could not redirect stderr: {e}")
return logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Config helpers
# ---------------------------------------------------------------------------
def generate_self_signed_cert(cert_file: Path, key_file: Path):
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
logger.info("Generating self-signed SSL certificate...")
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "AISBF"),
x509.NameAttribute(NameOID.COMMON_NAME, "localhost"),
])
cert = (x509.CertificateBuilder()
.subject_name(subject).issuer_name(issuer)
.public_key(private_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.utcnow())
.not_valid_after(datetime.utcnow() + timedelta(days=365))
.add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False)
.sign(private_key, hashes.SHA256()))
key_file.parent.mkdir(parents=True, exist_ok=True)
with open(key_file, "wb") as f:
f.write(private_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption()
))
with open(cert_file, "wb") as f:
f.write(cert.public_bytes(serialization.Encoding.PEM))
logger.info(f"Generated self-signed certificate: {cert_file}")
def get_aisbf_config_path(custom_config_dir=None) -> Path:
candidates = []
if custom_config_dir:
candidates.append(Path(custom_config_dir) / 'aisbf.json')
candidates += [
Path.home() / '.aisbf' / 'aisbf.json',
Path.home() / '.local' / 'share' / 'aisbf' / 'aisbf.json',
Path('/usr/local/share/aisbf/aisbf.json'),
Path('/usr/share/aisbf/aisbf.json'),
Path(__file__).parent.parent.parent / 'config' / 'aisbf.json',
]
for p in candidates:
if p.exists():
return p
return candidates[-1]
def load_server_config(custom_config_dir=None):
config_path = None
if custom_config_dir:
p = Path(custom_config_dir) / 'aisbf.json'
if p.exists():
config_path = p
if not config_path:
config_path = Path.home() / '.aisbf' / 'aisbf.json'
if not config_path.exists():
for d in [Path('/usr/share/aisbf'), Path.home() / '.local' / 'share' / 'aisbf']:
t = d / 'aisbf.json'
if t.exists():
config_path = t
break
else:
t = Path(__file__).parent.parent.parent / 'config' / 'aisbf.json'
if t.exists():
config_path = t
if config_path and config_path.exists():
try:
with open(config_path) as f:
data = json.load(f)
srv = data.get('server', {})
auth = data.get('auth', {})
protocol = srv.get('protocol', 'http')
ssl_certfile = srv.get('ssl_certfile')
ssl_keyfile = srv.get('ssl_keyfile')
if protocol == 'https':
if not ssl_certfile or not ssl_keyfile:
ssl_dir = Path.home() / '.aisbf' / 'ssl'
ssl_certfile = str(ssl_dir / 'cert.pem')
ssl_keyfile = str(ssl_dir / 'key.pem')
cert_path = Path(ssl_certfile).expanduser()
key_path = Path(ssl_keyfile).expanduser()
if not cert_path.exists() or not key_path.exists():
generate_self_signed_cert(cert_path, key_path)
return {
'host': srv.get('host', '0.0.0.0'),
'port': srv.get('port', 17765),
'protocol': protocol,
'ssl_certfile': ssl_certfile if protocol == 'https' else None,
'ssl_keyfile': ssl_keyfile if protocol == 'https' else None,
'auth_enabled': auth.get('enabled', False),
'auth_tokens': auth.get('tokens', [])
}
except Exception as e:
logging.getLogger(__name__).warning(f"Error loading aisbf.json: {e}, using defaults")
return {'host': '0.0.0.0', 'port': 17765, 'protocol': 'http',
'ssl_certfile': None, 'ssl_keyfile': None,
'auth_enabled': False, 'auth_tokens': []}
def _get_or_create_session_secret():
secret_file = Path.home() / '.aisbf' / 'session_secret.key'
if secret_file.exists():
try:
with open(secret_file) as f:
return f.read().strip()
except Exception:
pass
secret = secrets.token_urlsafe(32)
try:
secret_file.parent.mkdir(parents=True, exist_ok=True)
with open(secret_file, 'w') as f:
f.write(secret)
os.chmod(secret_file, 0o600)
except Exception:
pass
return secret
# ---------------------------------------------------------------------------
# Config reload helpers
# ---------------------------------------------------------------------------
def _reload_global_config():
logger = logging.getLogger(__name__)
with _config_reload_lock:
try:
from aisbf.config import config as _global_cfg
if _global_cfg is not None:
_global_cfg.reload()
logger.info("Global config hot-reloaded after dashboard change")
except Exception as e:
logger.error(f"Error reloading global config: {e}", exc_info=True)
def _apply_condense_defaults_provider(provider: dict):
for model in provider.get('models', []):
if isinstance(model, dict) and model.get('condense_method') and not model.get('condense_context'):
model['condense_context'] = 80
def _apply_condense_defaults_rotation(rotation: dict):
for prov in rotation.get('providers', []):
for model in prov.get('models', []):
if isinstance(model, dict) and model.get('condense_method') and not model.get('condense_context'):
model['condense_context'] = 80
def _providers_json_path():
p = Path.home() / '.aisbf' / 'providers.json'
if not p.exists():
p = Path(__file__).parent.parent.parent / 'config' / 'providers.json'
return p
def _rotations_json_path():
p = Path.home() / '.aisbf' / 'rotations.json'
if not p.exists():
p = Path(__file__).parent.parent.parent / 'config' / 'rotations.json'
return p
def _autoselect_json_path():
p = Path.home() / '.aisbf' / 'autoselect.json'
if not p.exists():
p = Path(__file__).parent.parent.parent / 'config' / 'autoselect.json'
return p
# ---------------------------------------------------------------------------
# Admin notifications
# ---------------------------------------------------------------------------
def _get_admin_notifications_config(config) -> dict:
defaults = {
'new_user_signup': False, 'payment_received': False,
'tier_upgrade': False, 'tier_downgrade': False,
'subscription_expired': False, 'subscription_renewed': False,
'wallet_topup': False, 'user_deleted_account': False,
}
try:
if config and config.aisbf and hasattr(config.aisbf, 'dashboard') and config.aisbf.dashboard:
notif = getattr(config.aisbf.dashboard, 'notifications', None)
if notif:
nd = notif if isinstance(notif, dict) else vars(notif)
defaults.update({k: bool(v) for k, v in nd.items() if k in defaults})
except Exception:
pass
return defaults
def _get_admin_email(config) -> str:
try:
if config and config.aisbf and hasattr(config.aisbf, 'dashboard') and config.aisbf.dashboard:
return getattr(config.aisbf.dashboard, 'email', '') or ''
except Exception:
pass
return ''
def _send_admin_notification_email(config, event_key: str, subject: str, body_html: str):
try:
if not _get_admin_notifications_config(config).get(event_key, False):
return
admin_email = _get_admin_email(config)
if not admin_email:
return
smtp_cfg = None
if config and config.aisbf and hasattr(config.aisbf, 'smtp'):
smtp_cfg = config.aisbf.smtp
if not smtp_cfg or not getattr(smtp_cfg, 'enabled', False):
return
from aisbf.email_utils import send_simple_email
send_simple_email(admin_email, subject, body_html, smtp_cfg)
except Exception as e:
logging.getLogger(__name__).warning(f"_send_admin_notification_email({event_key}): {e}")
# ---------------------------------------------------------------------------
# Login rate limiter
# ---------------------------------------------------------------------------
_login_failures: dict = {}
_LOGIN_MAX_ATTEMPTS = 10
_LOGIN_WINDOW_SECS = 300
_LOGIN_LOCKOUT_SECS = 600
def _login_rate_limit_check(ip: str, username: str) -> bool:
key = f"{ip}:{username.lower()}"
now = time.time()
attempts = [t for t in _login_failures.get(key, []) if now - t < _LOGIN_WINDOW_SECS]
_login_failures[key] = attempts
return len(attempts) >= _LOGIN_MAX_ATTEMPTS
def _login_record_failure(ip: str, username: str) -> None:
_login_failures.setdefault(f"{ip}:{username.lower()}", []).append(time.time())
def _login_clear_failures(ip: str, username: str) -> None:
_login_failures.pop(f"{ip}:{username.lower()}", None)
# ---------------------------------------------------------------------------
# Handler cache
# ---------------------------------------------------------------------------
def get_user_handler(handler_type: str, request_handler, rotation_handler, autoselect_handler, user_id=None):
from aisbf.handlers import RequestHandler, RotationHandler, AutoselectHandler
if user_id is None:
if handler_type == 'request':
return request_handler
elif handler_type == 'rotation':
return rotation_handler
elif handler_type == 'autoselect':
return autoselect_handler
raise ValueError(f"Unknown handler type: {handler_type}")
cache_key = f"{handler_type}_{user_id}"
if cache_key in _user_handlers_cache:
return _user_handlers_cache[cache_key]
if handler_type == 'request':
handler = RequestHandler(user_id)
elif handler_type == 'rotation':
handler = RotationHandler(user_id)
elif handler_type == 'autoselect':
handler = AutoselectHandler(user_id)
else:
raise ValueError(f"Unknown handler type: {handler_type}")
_user_handlers_cache[cache_key] = handler
return handler
# ---------------------------------------------------------------------------
# App initialization
# ---------------------------------------------------------------------------
def initialize_app(app_state: dict, custom_config_dir=None):
"""Initialize app globals. app_state is a dict that holds config, handlers, etc."""
logger = logging.getLogger(__name__)
if app_state.get('_initialized'):
return
if custom_config_dir:
set_config_dir(custom_config_dir)
logger.info(f"Using custom config directory: {custom_config_dir}")
import shutil as _shutil
from aisbf.providers.claude_cli import detect_claude_cli
if detect_claude_cli():
app_state['_claude_cli_mode'] = True
import aisbf.providers.claude_cli as _cli_mode_mod
logger.info(f"Claude CLI detected at {_cli_mode_mod.CLAUDE_CLI_PATH} – CLI proxy mode enabled")
else:
logger.info("Claude CLI not found in PATH – using HTTP API mode")
from aisbf.config import config as cfg
from aisbf.handlers import RequestHandler, RotationHandler, AutoselectHandler
app_state['config'] = cfg
app_state['request_handler'] = RequestHandler()
app_state['rotation_handler'] = RotationHandler()
app_state['autoselect_handler'] = AutoselectHandler()
app_state['server_config'] = load_server_config(custom_config_dir)
aisbf_config_path = get_aisbf_config_path(custom_config_dir)
if aisbf_config_path.exists():
with open(aisbf_config_path) as f:
aisbf_config = json.load(f)
app_state['server_config']['dashboard_config'] = aisbf_config.get('dashboard', {})
else:
app_state['server_config']['dashboard_config'] = {
'username': 'admin',
'password': '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918'
}
app_state['_initialized'] = True
logger.info("App initialization complete")
# ---------------------------------------------------------------------------
# Multiprocessing / signal cleanup
# ---------------------------------------------------------------------------
def _cleanup_multiprocessing_children():
logger = logging.getLogger(__name__)
try:
active = multiprocessing.active_children()
if active:
logger.info(f"Terminating {len(active)} multiprocessing child process(es)...")
for child in active:
child.terminate()
for child in active:
child.join(timeout=2)
for child in multiprocessing.active_children():
child.kill()
except Exception as e:
logger.warning(f"Error cleaning up multiprocessing children: {e}")
def _signal_handler(signum, frame):
logger = logging.getLogger(__name__)
sig_name = signal.Signals(signum).name
logger.info(f"Received {sig_name}, shutting down...")
_cleanup_multiprocessing_children()
signal.signal(signum, signal.SIG_DFL)
os.kill(os.getpid(), signum)
def register_signal_handlers():
signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler)
atexit.register(_cleanup_multiprocessing_children)
"""
Jinja2 template setup, proxy-aware URL helpers, and ProxyHeadersMiddleware.
Extracted from main.py.
"""
import hashlib
import logging
from pathlib import Path
from fastapi import Request
from fastapi.templating import Jinja2Templates
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger(__name__)
class ProxyHeadersMiddleware(BaseHTTPMiddleware):
"""Handle X-Forwarded-* proxy headers."""
async def dispatch(self, request: Request, call_next):
forwarded_proto = request.headers.get("X-Forwarded-Proto")
forwarded_host = request.headers.get("X-Forwarded-Host")
forwarded_port = request.headers.get("X-Forwarded-Port")
forwarded_prefix = request.headers.get("X-Forwarded-Prefix") or request.headers.get("X-Script-Name")
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_proto or forwarded_host or forwarded_prefix:
logger.debug(f"Proxy headers detected - Proto: {forwarded_proto}, Host: {forwarded_host}, Prefix: {forwarded_prefix}")
if forwarded_proto:
request.scope["scheme"] = forwarded_proto
if forwarded_host:
if ":" in forwarded_host and not forwarded_port:
host_parts = forwarded_host.split(":", 1)
request.scope["server"] = (host_parts[0], int(host_parts[1]))
else:
port = int(forwarded_port) if forwarded_port else (443 if forwarded_proto == "https" else 80)
request.scope["server"] = (forwarded_host, port)
elif forwarded_port:
current_host = request.scope.get("server", ("localhost", 80))[0]
request.scope["server"] = (current_host, int(forwarded_port))
if forwarded_prefix:
forwarded_prefix = forwarded_prefix.rstrip("/")
request.scope["root_path"] = forwarded_prefix
original_path = request.scope.get("path", "")
if original_path.startswith(forwarded_prefix):
request.scope["path"] = original_path[len(forwarded_prefix):] or "/"
if forwarded_for:
client_ip = forwarded_for.split(",")[0].strip()
request.scope["client"] = (client_ip, request.scope.get("client", ("", 0))[1])
return await call_next(request)
def get_base_url(request: Request) -> str:
scheme = request.scope.get("scheme", "http")
server = request.scope.get("server", ("localhost", 80))
host, port = server[0], server[1]
root_path = request.scope.get("root_path", "")
if (scheme == "http" and port == 80) or (scheme == "https" and port == 443):
return f"{scheme}://{host}{root_path}"
return f"{scheme}://{host}:{port}{root_path}"
def url_for(request: Request, path: str) -> str:
root_path = request.scope.get("root_path", "")
if not path.startswith("/"):
path = "/" + path
is_behind_proxy = "x-forwarded-host" in request.headers or "x-forwarded-proto" in request.headers
if is_behind_proxy:
return (root_path + path) if (root_path and root_path != "/") else path
return f"{get_base_url(request)}{path}"
def create_templates(template_dir: str) -> Jinja2Templates:
templates = Jinja2Templates(directory=template_dir)
templates.env.loader.searchpath.insert(0, template_dir)
return templates
def setup_template_globals(templates: Jinja2Templates, version: str):
from aisbf import __version__
def md5_filter(s):
if not s:
return hashlib.md5(b'').hexdigest().lower()
return hashlib.md5(s.encode('utf-8')).hexdigest().lower()
templates.env.filters['md5'] = md5_filter
templates.env.globals['url_for'] = url_for
templates.env.globals['get_base_url'] = get_base_url
templates.env.globals['__version__'] = version
templates.env.cache.clear()
def patch_template_response(templates: Jinja2Templates):
"""Inject is_aisbf_cloud / welcome_shown into every TemplateResponse automatically."""
original = templates.TemplateResponse
def patched(*args, **kwargs):
if 'context' in kwargs and 'request' in kwargs['context']:
req = kwargs['context']['request']
if hasattr(req.state, 'is_aisbf_cloud'):
kwargs['context']['is_aisbf_cloud'] = req.state.is_aisbf_cloud
if hasattr(req.state, 'welcome_shown'):
kwargs['context']['welcome_shown'] = req.state.welcome_shown
return original(*args, **kwargs)
templates.TemplateResponse = patched
...@@ -27,6 +27,7 @@ import re ...@@ -27,6 +27,7 @@ import re
import uuid import uuid
import hashlib import hashlib
import threading import threading
import time
import time as time_module import time as time_module
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List, Optional, Union from typing import Dict, List, Optional, Union
...@@ -45,7 +46,6 @@ from .context import ContextManager, get_context_config_for_model ...@@ -45,7 +46,6 @@ from .context import ContextManager, get_context_config_for_model
from .classifier import content_classifier from .classifier import content_classifier
from .classifier import SemanticClassifier from .classifier import SemanticClassifier
from .cache import get_response_cache from .cache import get_response_cache
import time as time_module
from .analytics import get_analytics from .analytics import get_analytics
from .streaming_optimization import ( from .streaming_optimization import (
get_streaming_optimizer, get_streaming_optimizer,
......
...@@ -915,21 +915,6 @@ class BaseProviderHandler: ...@@ -915,21 +915,6 @@ class BaseProviderHandler:
logger.info(f"[{self.provider_id}] API key present, validation passed") logger.info(f"[{self.provider_id}] API key present, validation passed")
return True return True
# Check if API key is provided
if not self.api_key:
logger.error(f"[{self.provider_id}] API key required but not provided")
return False
# Check for placeholder/empty API key
if isinstance(self.api_key, str):
stripped = self.api_key.strip()
if not stripped or stripped.startswith('YOUR_') or 'placeholder' in stripped.lower():
logger.error(f"[{self.provider_id}] Invalid API key format")
return False
logger.info(f"[{self.provider_id}] API key present, validation passed")
return True
def parse_429_response(self, response_data: Union[Dict, str], headers: Dict = None) -> Optional[int]: def parse_429_response(self, response_data: Union[Dict, str], headers: Dict = None) -> Optional[int]:
""" """
......
...@@ -204,7 +204,652 @@ class ClaudeProviderHandler(BaseProviderHandler): ...@@ -204,7 +204,652 @@ class ClaudeProviderHandler(BaseProviderHandler):
# Initialize persistent identifiers for metadata # Initialize persistent identifiers for metadata
self._init_session_identifiers() self._init_session_identifiers()
def _init_session_identifiers(self):
"""Initialize persistent session identifiers (device_id, account_uuid)."""
import uuid
import hashlib
if not self.session_state.get('device_id'):
device_seed = f"{self.provider_id}-{time.time()}"
self.session_state['device_id'] = hashlib.sha256(device_seed.encode()).hexdigest()
if not self.session_state.get('account_uuid'):
account_id = self.auth.get_account_id() if hasattr(self.auth, 'get_account_id') else None
self.session_state['account_uuid'] = account_id if account_id else str(uuid.uuid4())
def _get_api_token(self) -> Optional[str]:
"""Return the configured API token (api_key), or None if using OAuth2."""
return self.api_key or None
def _load_auth_from_db(self, provider_id: str, credentials_file: str):
"""
Load OAuth2 credentials:
- Admin users (user_id=None): ONLY load from file
- Regular users: ONLY load from database, NO file fallback
"""
from ..auth.claude import ClaudeAuth
import logging
if self.user_id is None:
# Admin user: ONLY use file-based credentials
logging.getLogger(__name__).info(f"ClaudeProviderHandler: Admin user, loading credentials from file: {credentials_file}")
return ClaudeAuth(credentials_file=credentials_file)
# Regular user: ONLY use database credentials, NO file fallback
try:
from ..database import DatabaseRegistry
db = DatabaseRegistry.get_config_database()
if db:
db_creds = db.get_user_oauth2_credentials(
user_id=self.user_id,
provider_id=provider_id,
auth_type='claude_oauth2'
)
if db_creds and db_creds.get('credentials'):
# Create auth instance with skip_initial_load=True to avoid file read
# Pass save callback to save credentials back to database
auth = ClaudeAuth(
credentials_file=credentials_file,
skip_initial_load=True,
save_callback=lambda creds: self._save_auth_to_db(creds)
)
# Set tokens directly from database
auth.tokens = db_creds['credentials'].get('tokens', {})
# Add expires_at if missing (for existing credentials saved before fix)
if auth.tokens and 'expires_at' not in auth.tokens and 'expires_in' in auth.tokens:
import time
auth.tokens['expires_at'] = time.time() + auth.tokens.get('expires_in', 3600)
import logging
logging.getLogger(__name__).info(f"ClaudeProviderHandler: Loaded credentials from database for user {self.user_id}")
return auth
except Exception as e:
logging.getLogger(__name__).warning(f"ClaudeProviderHandler: Failed to load credentials from database: {e}")
# For regular users, NO file fallback - return empty auth instance
logging.getLogger(__name__).info(f"ClaudeProviderHandler: No database credentials found for user {self.user_id}, returning unauthenticated instance")
return ClaudeAuth(credentials_file=credentials_file, skip_initial_load=True)
# ------------------------------------------------------------------ #
# Claude CLI mode helpers #
# ------------------------------------------------------------------ #
def _get_cli_credentials(self) -> Optional[dict]:
"""
Return the Claude CLI .credentials.json content for this user/provider,
or None if CLI credentials are not configured.
Priority order:
1. Explicit CLI credentials file (admin) or uploaded CLI credentials (DB user)
2. If claude_config.use_cli_mode is true, derive from existing OAuth2 tokens
"""
logger = _logging.getLogger(__name__)
if isinstance(self.provider_config, dict):
claude_cfg = self.provider_config.get('claude_config', {}) or {}
else:
claude_cfg = getattr(self.provider_config, 'claude_config', {}) or {}
use_cli_mode = bool(claude_cfg.get('use_cli_mode')) if isinstance(claude_cfg, dict) else False
if self.user_id is None:
# ── Config admin ──────────────────────────────────────────────
cli_file = claude_cfg.get('cli_credentials_file') if isinstance(claude_cfg, dict) else None
if cli_file:
expanded = os.path.expanduser(cli_file)
if not os.path.exists(expanded):
logger.warning(f"ClaudeCliMode: CLI credentials file not found: {expanded}")
else:
try:
with open(expanded) as fh:
return json.load(fh)
except Exception as exc:
logger.warning(f"ClaudeCliMode: failed to read CLI credentials file: {exc}")
# Fall back: derive from existing OAuth2 tokens when use_cli_mode is set
if use_cli_mode and self.auth and self.auth.tokens:
logger.info("ClaudeCliMode: building CLI credentials from existing OAuth2 tokens (admin)")
return self._oauth_tokens_to_cli_credentials(self.auth.tokens)
return None
else:
# ── DB user ───────────────────────────────────────────────────
try:
from ..database import DatabaseRegistry
db = DatabaseRegistry.get_config_database()
if db:
# 1. Check for explicit uploaded CLI credentials
row = db.get_user_oauth2_credentials(
user_id=self.user_id,
provider_id=self.provider_id,
auth_type='claude_cli_credentials',
)
if row and row.get('credentials'):
return row['credentials'].get('credentials')
# 2. Derive from existing OAuth2 tokens when use_cli_mode is set
if use_cli_mode:
oauth_row = db.get_user_oauth2_credentials(
user_id=self.user_id,
provider_id=self.provider_id,
auth_type='claude_oauth2',
)
if oauth_row and oauth_row.get('credentials'):
tokens = oauth_row['credentials'].get('tokens', {})
if tokens:
logger.info(
f"ClaudeCliMode: building CLI credentials from "
f"OAuth2 tokens for user {self.user_id}"
)
return self._oauth_tokens_to_cli_credentials(tokens)
except Exception as exc:
logger.warning(f"ClaudeCliMode: failed to load credentials: {exc}")
return None
def _messages_to_cli_prompt(self, messages: List[Dict],
tools: Optional[List[Dict]] = None) -> str:
"""
Convert an OpenAI-style messages list (plus optional tool definitions)
to a flat text prompt for the claude CLI sent via stdin.
System messages and tool definitions are included as a prefix.
"""
system_parts: List[str] = []
turn_parts: List[str] = []
for msg in messages:
role = msg.get('role', '')
content = msg.get('content', '')
if isinstance(content, list):
fragments = []
for block in content:
if isinstance(block, dict) and block.get('type') == 'text':
fragments.append(block.get('text', ''))
elif isinstance(block, str):
fragments.append(block)
content = '\n'.join(fragments)
elif not isinstance(content, str):
content = str(content)
if role == 'system':
system_parts.append(content.strip())
elif role == 'user':
turn_parts.append(f'Human: {content}')
elif role == 'assistant':
turn_parts.append(f'Assistant: {content}')
if tools:
tools_json = json.dumps(tools, ensure_ascii=False)
system_parts.append(
f'Available tools (respond with tool_use blocks as needed):\n{tools_json}'
)
parts: List[str] = []
if system_parts:
parts.append('[System Instructions: ' + '\n'.join(system_parts) + ']')
parts.extend(turn_parts)
return '\n\n'.join(parts)
async def _cli_discover_models(self, config_dir: str) -> List['Model']:
"""
Ask the claude CLI which models it supports using --output-format json.
Returns a list of Model objects parsed from the JSON result.
The single-object JSON output format (not stream-json) is used here
because it carries a `modelUsage` map with real contextWindow metadata,
and the `result` text lists all models Claude knows about.
"""
import re
logger = _logging.getLogger(__name__)
env = os.environ.copy()
env['CLAUDE_CONFIG_DIR'] = config_dir
env['CLAUDE_CODE_USE_KEYCHAIN'] = 'false'
prompt = (
"Which models are you compatible with? "
"Give me only a JSON list without any other comment or word "
"except for the list of the model IDs."
)
cmd = [
'claude', '-p', prompt,
'--output-format', 'json',
'--dangerously-skip-permissions',
'--no-session-persistence',
]
logger.info(
"ClaudeCliMode: model discovery subprocess\n"
f" Replicate with: CLAUDE_CONFIG_DIR={config_dir} CLAUDE_CODE_USE_KEYCHAIN=false "
+ ' '.join(cmd)
)
process = await asyncio.create_subprocess_exec(
*cmd,
env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(
process.communicate(), timeout=60.0
)
except asyncio.TimeoutError:
logger.error("ClaudeCliMode: model discovery subprocess timed out")
process.kill()
await process.wait()
return []
if stderr_bytes:
logger.debug(
f"ClaudeCliMode: discovery stderr:\n"
f"{stderr_bytes.decode('utf-8', errors='replace')[:2000]}"
)
stdout_str = stdout_bytes.decode('utf-8', errors='replace').strip()
logger.debug(f"ClaudeCliMode: discovery raw output: {stdout_str[:1000]}")
if not stdout_str:
logger.warning("ClaudeCliMode: model discovery returned empty output")
return []
try:
data = json.loads(stdout_str)
except json.JSONDecodeError as e:
logger.warning(f"ClaudeCliMode: model discovery JSON parse error: {e}")
return []
if data.get('is_error') or data.get('subtype') != 'success':
logger.warning(
f"ClaudeCliMode: model discovery error: {data.get('result', '')[:200]}"
)
return []
# modelUsage keys → real metadata (contextWindow, maxOutputTokens)
# Note: only models actually invoked in this call appear here; haiku is
# used for internal routing so it shows up even though we didn't ask for it.
model_usage: dict = data.get('modelUsage', {})
# result text contains the JSON list we asked for, possibly wrapped in
# a markdown code fence like ```json\n[...]\n```
result_text: str = data.get('result', '')
logger.info(f"ClaudeCliMode: discovery result: {result_text!r}")
# Parse the JSON array from result (strip code fences if present)
json_match = re.search(r'\[[\s\S]*?\]', result_text)
result_ids: set = set()
if json_match:
try:
parsed = json.loads(json_match.group())
if isinstance(parsed, list):
result_ids = {m for m in parsed if isinstance(m, str) and m.startswith('claude-')}
except json.JSONDecodeError:
pass
# Fall back to regex scan of the result text if JSON parse failed
if not result_ids:
result_ids = set(re.findall(r'claude-[a-z0-9][a-z0-9.\-]*[a-z0-9]', result_text))
logger.info(f"ClaudeCliMode: model IDs from result: {sorted(result_ids)}")
logger.info(f"ClaudeCliMode: model IDs from modelUsage: {sorted(model_usage.keys())}")
# Known context window overrides — avoids a costly second prompt.
# modelUsage carries real values for models used in this call; for the
# rest we apply these known constants rather than querying Claude again.
_known_context: dict = {
'claude-opus-4-7': 1000000,
}
# Union: result_ids is the authoritative list; modelUsage adds metadata
all_ids = result_ids | set(model_usage.keys())
if not all_ids:
return []
models = []
for mid in sorted(all_ids):
usage_meta = model_usage.get(mid, {})
context_size = (
usage_meta.get('contextWindow')
or _known_context.get(mid)
or 200000
)
max_output = usage_meta.get('maxOutputTokens')
m = Model(
id=mid,
name=mid,
provider_id=self.provider_id,
context_size=context_size,
context_length=context_size,
)
if max_output:
m.max_output_tokens = max_output
models.append(m)
return models
async def _handle_cli_streaming_request(self, prompt: str, model: str, config_dir: str):
"""
Spawn a claude CLI subprocess, stream its JSON output, and yield
OpenAI-compatible SSE chunks. Multiple parallel calls each get their
own subprocess; the config_dir is shared (read-only at runtime).
"""
logger = _logging.getLogger(__name__)
clean_model = model.split('/')[-1] if '/' in model else model
env = os.environ.copy()
env['CLAUDE_CONFIG_DIR'] = config_dir
env['CLAUDE_CODE_USE_KEYCHAIN'] = 'false'
cmd = [
'stdbuf', '-oL',
'claude', '-p',
'--input-format', 'stream-json',
'--output-format', 'stream-json',
'--include-partial-messages',
'--tools', '',
'--dangerously-skip-permissions',
'--no-session-persistence',
'--verbose',
]
if clean_model:
cmd += ['--model', clean_model]
stdin_payload: Dict = {
'type': 'user_message',
'content': [{'type': 'text', 'text': prompt}],
}
input_msg = json.dumps(stdin_payload) + '\n'
# Log a shell-replicable command for debugging
cmd_str = ' '.join(cmd)
logger.info(
f"ClaudeCliMode: launching subprocess model={clean_model} dir={config_dir}\n"
f" Replicate with: CLAUDE_CONFIG_DIR={config_dir} CLAUDE_CODE_USE_KEYCHAIN=false "
f"{cmd_str} <<'EOF'\n{input_msg.strip()}\nEOF"
)
process = await asyncio.create_subprocess_exec(
*cmd,
env=env,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
process.stdin.write(input_msg.encode())
await process.stdin.drain()
process.stdin.close()
completion_id = f'chatcmpl-cli-{int(time.time())}'
created_time = int(time.time())
first_chunk = True
# State for accumulating tool_use blocks
# { block_index: {"id": ..., "name": ..., "arguments": ""} }
tool_blocks: dict = {}
tool_header_sent: set = set()
cli_prev_text_len: int = 0
try:
while True:
try:
raw = await asyncio.wait_for(process.stdout.readline(), timeout=120.0)
except asyncio.TimeoutError:
logger.error("ClaudeCliMode: subprocess read timeout (120 s)")
break
if not raw:
break
line_str = raw.decode('utf-8', errors='replace').strip()
if not line_str:
continue
logger.debug(f"ClaudeCliMode: raw event: {line_str}")
try:
data = json.loads(line_str)
except json.JSONDecodeError:
logger.debug(f"ClaudeCliMode: non-JSON line: {line_str}")
continue
event_type = data.get('type')
if event_type == 'content_block_start':
cb = data.get('content_block', {})
if cb.get('type') == 'tool_use':
idx = data.get('index', 0)
tool_blocks[idx] = {
'id': cb.get('id', f'call_{idx}'),
'name': cb.get('name', ''),
'arguments': '',
}
logger.debug(f"ClaudeCliMode: tool_use block started idx={idx} name={cb.get('name')}")
elif event_type == 'content_block_delta':
delta = data.get('delta', {})
idx = data.get('index', 0)
if delta.get('type') == 'text_delta':
text = delta.get('text', '')
if not text:
continue
if first_chunk:
yield f'data: {json.dumps({"id": completion_id, "object": "chat.completion.chunk", "created": created_time, "model": f"{self.provider_id}/{clean_model}", "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}]})}\n\n'
first_chunk = False
yield f'data: {json.dumps({"id": completion_id, "object": "chat.completion.chunk", "created": created_time, "model": f"{self.provider_id}/{clean_model}", "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": None}]})}\n\n'
elif delta.get('type') == 'input_json_delta' and idx in tool_blocks:
partial = delta.get('partial_json', '')
tool_blocks[idx]['arguments'] += partial
# Emit streaming tool_calls delta
if idx not in tool_header_sent:
tool_header_sent.add(idx)
if first_chunk:
yield f'data: {json.dumps({"id": completion_id, "object": "chat.completion.chunk", "created": created_time, "model": f"{self.provider_id}/{clean_model}", "choices": [{"index": 0, "delta": {"role": "assistant", "content": None, "tool_calls": [{"index": idx, "id": tool_blocks[idx]["id"], "type": "function", "function": {"name": tool_blocks[idx]["name"], "arguments": ""}}]}, "finish_reason": None}]})}\n\n'
first_chunk = False
else:
yield f'data: {json.dumps({"id": completion_id, "object": "chat.completion.chunk", "created": created_time, "model": f"{self.provider_id}/{clean_model}", "choices": [{"index": 0, "delta": {"tool_calls": [{"index": idx, "id": tool_blocks[idx]["id"], "type": "function", "function": {"name": tool_blocks[idx]["name"], "arguments": ""}}]}, "finish_reason": None}]})}\n\n'
if partial:
yield f'data: {json.dumps({"id": completion_id, "object": "chat.completion.chunk", "created": created_time, "model": f"{self.provider_id}/{clean_model}", "choices": [{"index": 0, "delta": {"tool_calls": [{"index": idx, "function": {"arguments": partial}}]}, "finish_reason": None}]})}\n\n'
elif event_type == 'assistant':
# Claude CLI stream-json format: partial or final assistant message
msg = data.get('message', {})
last_text = ''
for block in msg.get('content', []):
if not isinstance(block, dict):
continue
btype = block.get('type')
if btype == 'text':
last_text += block.get('text', '')
elif btype == 'tool_use':
# Tool call in assistant event — register and emit if not yet seen
tc_id = block.get('id', f'call_{len(tool_blocks)}')
if tc_id not in tool_header_sent:
tool_header_sent.add(tc_id)
idx = len(tool_blocks)
tool_blocks[idx] = {
'id': tc_id,
'name': block.get('name', ''),
'arguments': json.dumps(block.get('input', {}), ensure_ascii=False),
}
role_delta = {'role': 'assistant', 'content': None} if first_chunk else {}
first_chunk = False
yield f'data: {json.dumps({"id": completion_id, "object": "chat.completion.chunk", "created": created_time, "model": f"{self.provider_id}/{clean_model}", "choices": [{"index": 0, "delta": {**role_delta, "tool_calls": [{"index": idx, "id": tc_id, "type": "function", "function": {"name": tool_blocks[idx]["name"], "arguments": tool_blocks[idx]["arguments"]}}]}, "finish_reason": None}]})}\n\n'
if last_text:
# Content is cumulative; emit only new characters
new_text = last_text[cli_prev_text_len:]
cli_prev_text_len = len(last_text)
if new_text:
if first_chunk:
yield f'data: {json.dumps({"id": completion_id, "object": "chat.completion.chunk", "created": created_time, "model": f"{self.provider_id}/{clean_model}", "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}]})}\n\n'
first_chunk = False
yield f'data: {json.dumps({"id": completion_id, "object": "chat.completion.chunk", "created": created_time, "model": f"{self.provider_id}/{clean_model}", "choices": [{"index": 0, "delta": {"content": new_text}, "finish_reason": None}]})}\n\n'
elif event_type == 'result':
result_text = data.get('result', '')
logger.debug(f"ClaudeCliMode: result event, is_error={data.get('is_error')}, text_len={len(result_text)}")
# Only emit via result if we haven't already streamed content via other events
if result_text and first_chunk:
yield f'data: {json.dumps({"id": completion_id, "object": "chat.completion.chunk", "created": created_time, "model": f"{self.provider_id}/{clean_model}", "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}]})}\n\n'
yield f'data: {json.dumps({"id": completion_id, "object": "chat.completion.chunk", "created": created_time, "model": f"{self.provider_id}/{clean_model}", "choices": [{"index": 0, "delta": {"content": result_text}, "finish_reason": None}]})}\n\n'
first_chunk = False
break
elif event_type == 'message_stop':
logger.debug("ClaudeCliMode: received message_stop")
break
else:
logger.debug(f"ClaudeCliMode: unhandled event type={event_type}")
except Exception as exc:
logger.error(f"ClaudeCliMode: streaming error: {exc}", exc_info=True)
finally:
try:
stderr_bytes = await asyncio.wait_for(process.stderr.read(), timeout=2.0)
if stderr_bytes:
decoded = stderr_bytes.decode('utf-8', errors='replace')
logger.debug(f"ClaudeCliMode: stderr:\n{decoded[:2000]}")
except Exception:
pass
try:
process.terminate()
await asyncio.wait_for(process.wait(), timeout=5.0)
except Exception:
try:
process.kill()
except Exception:
pass
finish_reason = 'tool_calls' if tool_blocks else 'stop'
yield f'data: {json.dumps({"id": completion_id, "object": "chat.completion.chunk", "created": created_time, "model": f"{self.provider_id}/{clean_model}", "choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}]})}\n\n'
yield 'data: [DONE]\n\n'
async def _handle_cli_request(self, prompt: str, model: str, config_dir: str,
tools: Optional[List[Dict]] = None) -> dict:
"""Non-streaming CLI request using --output-format json with prompt via stdin."""
logger = _logging.getLogger(__name__)
clean_model = model.split('/')[-1] if '/' in model else model
env = os.environ.copy()
env['CLAUDE_CONFIG_DIR'] = config_dir
env['CLAUDE_CODE_USE_KEYCHAIN'] = 'false'
cmd = [
'claude', '-p',
'--output-format', 'json',
'--dangerously-skip-permissions',
'--no-session-persistence',
]
if tools:
cmd += ['--tools', json.dumps(tools, ensure_ascii=False)]
if clean_model:
cmd += ['--model', clean_model]
logger.info(
f"ClaudeCliMode: non-streaming subprocess model={clean_model} dir={config_dir}\n"
f" Replicate with: CLAUDE_CONFIG_DIR={config_dir} CLAUDE_CODE_USE_KEYCHAIN=false "
+ ' '.join(cmd) + f" <<'EOF'\n{prompt[:200]}...\nEOF"
)
process = await asyncio.create_subprocess_exec(
*cmd,
env=env,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(
process.communicate(input=prompt.encode()), timeout=120.0
)
except asyncio.TimeoutError:
logger.error("ClaudeCliMode: non-streaming subprocess timed out")
process.kill()
await process.wait()
return {
'id': f'chatcmpl-cli-{int(time.time())}',
'object': 'chat.completion',
'created': int(time.time()),
'model': f'{self.provider_id}/{clean_model}',
'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': 'Request timed out.'}, 'finish_reason': 'stop'}],
'usage': {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0},
}
if stderr_bytes:
logger.debug(f"ClaudeCliMode: stderr:\n{stderr_bytes.decode('utf-8', errors='replace')[:2000]}")
stdout_str = stdout_bytes.decode('utf-8', errors='replace').strip()
logger.debug(f"ClaudeCliMode: raw output: {stdout_str[:500]}")
result_text = ''
try:
data = json.loads(stdout_str)
if data.get('is_error'):
logger.warning(f"ClaudeCliMode: CLI returned error: {data.get('result', '')[:200]}")
result_text = data.get('result', '')
except json.JSONDecodeError:
result_text = stdout_str
return {
'id': f'chatcmpl-cli-{int(time.time())}',
'object': 'chat.completion',
'created': int(time.time()),
'model': f'{self.provider_id}/{clean_model}',
'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': result_text}, 'finish_reason': 'stop'}],
'usage': {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0},
}
@staticmethod
def _oauth_tokens_to_cli_credentials(tokens: dict) -> dict:
"""
Convert AISBF OAuth2 token dict to the Claude CLI .credentials.json schema:
AISBF stores: access_token, refresh_token, expires_at (seconds float), scope
CLI expects: claudeAiOauth.accessToken, .refreshToken, .expiresAt (ms int),
.scopes (list), .subscriptionType, .rateLimitTier
"""
default_scopes = [
'user:file_upload',
'user:inference',
'user:mcp_servers',
'user:profile',
'user:sessions:claude_code',
]
raw_scope = tokens.get('scope', '')
scopes = raw_scope.split() if raw_scope.strip() else default_scopes
expires_at_sec = tokens.get('expires_at', 0)
expires_at_ms = int(expires_at_sec * 1000) if expires_at_sec else 0
return {
'claudeAiOauth': {
'accessToken': tokens.get('access_token', ''),
'refreshToken': tokens.get('refresh_token', ''),
'expiresAt': expires_at_ms,
'scopes': scopes,
'subscriptionType': tokens.get('subscription_type', 'pro'),
'rateLimitTier': tokens.get('rate_limit_tier', 'default_claude_ai'),
}
}
def _save_auth_to_db(self, credentials: dict):
"""Save OAuth2 credentials back to the database."""
try:
from aisbf.database import DatabaseRegistry
db = DatabaseRegistry.get_config_database()
if db and self.user_id is not None:
db.save_provider_credentials(self.user_id, self.provider_id, credentials, auth_type='claude_oauth2')
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"ClaudeProviderHandler: Failed to save credentials to database: {e}")
def validate_credentials(self) -> bool: def validate_credentials(self) -> bool:
""" """
Validate Claude credentials. Validate Claude credentials.
...@@ -925,7 +1570,7 @@ class ClaudeProviderHandler(BaseProviderHandler): ...@@ -925,7 +1570,7 @@ class ClaudeProviderHandler(BaseProviderHandler):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Claude CLI mode (skipped when api_token is configured) ────── # ── Claude CLI mode (skipped when api_token is configured) ──────
import aisbf.cli_mode as cli_mode_mod import aisbf.providers.claude_cli as cli_mode_mod
if cli_mode_mod.CLAUDE_CLI_MODE and not self._get_api_token(): if cli_mode_mod.CLAUDE_CLI_MODE and not self._get_api_token():
cli_creds = self._get_cli_credentials() cli_creds = self._get_cli_credentials()
if cli_creds is not None: if cli_creds is not None:
...@@ -1829,7 +2474,7 @@ class ClaudeProviderHandler(BaseProviderHandler): ...@@ -1829,7 +2474,7 @@ class ClaudeProviderHandler(BaseProviderHandler):
await self.apply_rate_limit() await self.apply_rate_limit()
# [0/3] CLI subprocess model discovery # [0/3] CLI subprocess model discovery
import aisbf.cli_mode as cli_mode_mod import aisbf.providers.claude_cli as cli_mode_mod
if cli_mode_mod.CLAUDE_CLI_MODE: if cli_mode_mod.CLAUDE_CLI_MODE:
cli_creds = self._get_cli_credentials() cli_creds = self._get_cli_credentials()
if cli_creds is not None: if cli_creds is not None:
......
...@@ -92,6 +92,18 @@ class CodexProviderHandler(BaseProviderHandler): ...@@ -92,6 +92,18 @@ class CodexProviderHandler(BaseProviderHandler):
else getattr(provider_config, 'api_key', None)) if provider_config else None else getattr(provider_config, 'api_key', None)) if provider_config else None
self._use_api_key_mode = bool(api_key or _cfg_api_key) self._use_api_key_mode = bool(api_key or _cfg_api_key)
self._account_id = None # Will be extracted from ID token in OAuth2 mode self._account_id = None # Will be extracted from ID token in OAuth2 mode
# Base URL for API requests
_endpoint = (provider_config.get('endpoint') if isinstance(provider_config, dict)
else getattr(provider_config, 'endpoint', None)) if provider_config else None
self.base_url = (_endpoint or 'https://chatgpt.com/backend-api').rstrip('/')
# Initialize OpenAI client for API key mode
if self._use_api_key_mode:
effective_key = api_key or _cfg_api_key
self.client = OpenAI(api_key=effective_key, base_url=self.base_url)
else:
self.client = None
def validate_credentials(self) -> bool: def validate_credentials(self) -> bool:
""" """
......
...@@ -124,8 +124,7 @@ class KiloProviderHandler(BaseProviderHandler): ...@@ -124,8 +124,7 @@ class KiloProviderHandler(BaseProviderHandler):
endpoint = 'https://kilo.ai/api/openrouter/v1' endpoint = 'https://kilo.ai/api/openrouter/v1'
self._kilo_endpoint = endpoint self._kilo_endpoint = endpoint
self.client = OpenAI(base_url=endpoint, api_key=api_key or "placeholder")
self.client = OpenAI(base_url=endpoint, api_key=api_key or "placeholder")
def validate_credentials(self) -> bool: def validate_credentials(self) -> bool:
""" """
......
...@@ -28,6 +28,7 @@ import os ...@@ -28,6 +28,7 @@ import os
import json import json
import uuid import uuid
import logging import logging
from pathlib import Path
from typing import Dict, List, Optional, Union from typing import Dict, List, Optional, Union
from ...config import config from ...config import config
......
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse, Response, FileResponse
from typing import Optional
import time, logging, os, json
from pathlib import Path
from aisbf.models import ChatCompletionRequest
from aisbf.database import DatabaseRegistry
from aisbf.app.model_cache import get_provider_models, _refresh_provider_usage_if_stale, _background_tasks
router = APIRouter()
_config = None
_get_user_handler = None
_rotation_handler = None
_request_handler = None
def init(config, get_user_handler_fn, rotation_handler):
global _config, _get_user_handler, _rotation_handler, _request_handler
_config = config
_get_user_handler = get_user_handler_fn
_rotation_handler = rotation_handler
_request_handler = get_user_handler_fn('request', None)
logger = logging.getLogger(__name__)
def parse_provider_from_model(model: str) -> tuple[str, str]:
if '/' in model:
parts = model.split('/', 1)
return parts[0], parts[1]
return None, model
@router.get("/")
async def root():
return {
"message": "AI Proxy Server is running",
"providers": list(_config.providers.keys()),
"rotations": list(_config.rotations.keys()),
"autoselect": list(_config.autoselect.keys())
}
@router.get("/favicon.ico")
async def favicon():
search_paths = [
Path(__file__).parent.parent.parent / 'static' / 'extension' / 'icons' / 'icon16.png',
Path(__file__).parent.parent.parent / 'static' / 'favicon.ico',
Path.home() / '.local' / 'share' / 'aisbf' / 'static' / 'extension' / 'icons' / 'icon16.png',
]
for favicon_path in search_paths:
if favicon_path.exists():
return FileResponse(
path=favicon_path,
media_type="image/png" if favicon_path.suffix == '.png' else "image/x-icon"
)
return Response(status_code=204)
@router.get("/health")
async def health():
return {"status": "ok"}
@router.get("/api/v1/models/{model_id}")
async def v1_get_model(model_id: str, request: Request):
all_models_response = await v1_list_all_models(request)
all_models = all_models_response.get("data", [])
for model in all_models:
if model.get("id") == model_id:
return model
raise HTTPException(status_code=404, detail=f"Model '{model_id}' not found")
@router.post("/api/v1/completions")
async def v1_completions(request: Request):
body = await request.body()
data = json.loads(body) if body else {}
prompt = data.get("prompt", "")
model = data.get("model", "")
max_tokens = data.get("max_tokens", 2048)
temperature = data.get("temperature", 1.0)
messages = [{"role": "user", "content": prompt}]
chat_request = {"model": model, "messages": messages, "max_tokens": max_tokens, "temperature": temperature}
return await v1_chat_completions(chat_request, request)
@router.post("/api/v1/chat/completions")
async def v1_chat_completions(request: Request, body: ChatCompletionRequest):
logger.info(f"=== V1 CHAT COMPLETION REQUEST ===")
logger.info(f"Model: {body.model}")
provider_id, actual_model = parse_provider_from_model(body.model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
logger.info(f"Parsed provider: {provider_id}, model: {actual_model}")
body_dict = body.model_dump()
if provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
body_dict['model'] = actual_model
user_id = getattr(request.state, 'user_id', None)
token_id = getattr(request.state, 'token_id', None)
handler = _get_user_handler('autoselect', user_id)
if body.stream:
return await handler.handle_autoselect_streaming_request(actual_model, body_dict)
else:
return await handler.handle_autoselect_request(actual_model, body_dict, user_id, token_id)
if provider_id == "rotation" or provider_id == "rotations":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
body_dict['model'] = actual_model
user_id = getattr(request.state, 'user_id', None)
token_id = getattr(request.state, 'token_id', None)
handler = _get_user_handler('rotation', user_id)
return await handler.handle_rotation_request(actual_model, body_dict, user_id, token_id)
if provider_id == "autoselections":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
body_dict['model'] = actual_model
user_id = getattr(request.state, 'user_id', None)
token_id = getattr(request.state, 'token_id', None)
handler = _get_user_handler('autoselect', user_id)
if body.stream:
return await handler.handle_autoselect_streaming_request(actual_model, body_dict)
else:
return await handler.handle_autoselect_request(actual_model, body_dict, user_id, token_id)
if provider_id not in _config.providers:
raise HTTPException(status_code=404, detail=f"Provider '{provider_id}' not found. Available: {list(_config.providers.keys())}")
body_dict['model'] = actual_model
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
if body.stream:
return await handler.handle_streaming_chat_completion(request, provider_id, body_dict)
else:
return await handler.handle_chat_completion(request, provider_id, body_dict)
@router.get("/api/models")
async def list_all_models(request: Request):
logger.info("=== LIST ALL MODELS REQUEST ===")
all_models = []
user_id = None
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
try:
db = DatabaseRegistry.get_config_database()
token = auth_header.split(" ")[1]
user = db.get_user_by_token(token)
if user:
user_id = user.get("id")
except Exception as e:
logger.debug(f"Auth check failed for models request: {e}")
for provider_id, provider_config in _config.providers.items():
try:
provider_models = await get_provider_models(provider_id, provider_config, _config, user_id=user_id)
all_models.extend(provider_models)
except Exception as e:
logger.warning(f"Error listing models for provider {provider_id}: {e}")
for rotation_id, rotation_config in _config.rotations.items():
try:
all_models.append({'id': f"rotation/{rotation_id}", 'object': 'model', 'created': int(time.time()), 'owned_by': 'aisbf-rotation', 'type': 'rotation', 'rotation_id': rotation_id, 'model_name': rotation_config.model_name, 'capabilities': getattr(rotation_config, 'capabilities', [])})
except Exception as e:
logger.warning(f"Error listing rotation {rotation_id}: {e}")
for autoselect_id, autoselect_config in _config.autoselect.items():
try:
all_models.append({'id': f"autoselect/{autoselect_id}", 'object': 'model', 'created': int(time.time()), 'owned_by': 'aisbf-autoselect', 'type': 'autoselect', 'autoselect_id': autoselect_id, 'model_name': autoselect_config.model_name, 'description': autoselect_config.description, 'capabilities': getattr(autoselect_config, 'capabilities', [])})
except Exception as e:
logger.warning(f"Error listing autoselect {autoselect_id}: {e}")
logger.info(f"Returning {len(all_models)} total models")
return {"object": "list", "data": all_models}
@router.get("/api/v1/models")
async def v1_list_all_models(request: Request):
logger.info("=== V1 LIST ALL MODELS REQUEST ===")
all_models = []
user_id = None
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
try:
db = DatabaseRegistry.get_config_database()
token = auth_header.split(" ")[1]
user = db.get_user_by_token(token)
if user:
user_id = user.get("id")
except Exception as e:
logger.debug(f"Auth check failed for models request: {e}")
for provider_id, provider_config in _config.providers.items():
try:
provider_models = await get_provider_models(provider_id, provider_config, _config, user_id=user_id)
all_models.extend(provider_models)
except Exception as e:
logger.warning(f"Error listing models for provider {provider_id}: {e}")
for rotation_id, rotation_config in _config.rotations.items():
try:
all_models.append({'id': f"rotation/{rotation_id}", 'object': 'model', 'created': int(time.time()), 'owned_by': 'aisbf-rotation', 'type': 'rotation', 'rotation_id': rotation_id, 'model_name': rotation_config.model_name, 'capabilities': getattr(rotation_config, 'capabilities', [])})
except Exception as e:
logger.warning(f"Error listing rotation {rotation_id}: {e}")
for autoselect_id, autoselect_config in _config.autoselect.items():
try:
all_models.append({'id': f"autoselect/{autoselect_id}", 'object': 'model', 'created': int(time.time()), 'owned_by': 'aisbf-autoselect', 'type': 'autoselect', 'autoselect_id': autoselect_id, 'model_name': autoselect_config.model_name, 'description': autoselect_config.description, 'capabilities': getattr(autoselect_config, 'capabilities', [])})
except Exception as e:
logger.warning(f"Error listing autoselect {autoselect_id}: {e}")
logger.info(f"Returning {len(all_models)} total models")
return {"object": "list", "data": all_models}
@router.get("/v1/models")
async def v1_list_all_models_alias(request: Request):
return await v1_list_all_models(request)
@router.get("/v1/chat/models")
async def v1_chat_models_alias(request: Request):
return await v1_list_all_models(request)
@router.get("/models")
async def models_root_alias(request: Request):
return await v1_list_all_models(request)
@router.post("/api/v1/audio/transcriptions")
async def v1_audio_transcriptions(request: Request):
logger.info("=== V1 AUDIO TRANSCRIPTION REQUEST ===")
form = await request.form()
model = form.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
if provider_id == "rotation":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
selected_provider, selected_model = _rotation_handler._select_provider_and_model(actual_model)
provider_id = selected_provider
actual_model = selected_model
elif provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
autoselect_config = _config.autoselect[actual_model]
fallback = autoselect_config.fallback
if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1)
else:
if fallback in _config.rotations:
selected_provider, selected_model = _rotation_handler._select_provider_and_model(fallback)
provider_id = selected_provider
actual_model = selected_model
else:
raise HTTPException(status_code=400, detail=f"Invalid fallback configuration for autoselect '{actual_model}'")
if provider_id not in _config.providers:
raise HTTPException(status_code=400, detail=f"Provider '{provider_id}' not found. Available: {list(_config.providers.keys())}")
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
from starlette.datastructures import FormData
updated_form = FormData()
for key, value in form.items():
updated_form[key] = actual_model if key == 'model' else value
return await handler.handle_audio_transcription(request, provider_id, updated_form)
@router.post("/api/v1/audio/speech")
async def v1_audio_speech(request: Request, body: dict):
logger.info("=== V1 TEXT-TO-SPEECH REQUEST ===")
model = body.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
if provider_id == "rotation":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
selected_provider, selected_model = _rotation_handler._select_provider_and_model(actual_model)
provider_id = selected_provider
actual_model = selected_model
elif provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
autoselect_config = _config.autoselect[actual_model]
fallback = autoselect_config.fallback
if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1)
else:
if fallback in _config.rotations:
selected_provider, selected_model = _rotation_handler._select_provider_and_model(fallback)
provider_id = selected_provider
actual_model = selected_model
else:
raise HTTPException(status_code=400, detail=f"Invalid fallback configuration for autoselect '{actual_model}'")
if provider_id not in _config.providers:
raise HTTPException(status_code=400, detail=f"Provider '{provider_id}' not found. Available: {list(_config.providers.keys())}")
body['model'] = actual_model
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
return await handler.handle_text_to_speech(request, provider_id, body)
@router.post("/api/v1/images/generations")
async def v1_image_generations(request: Request, body: dict):
logger.info("=== V1 IMAGE GENERATION REQUEST ===")
model = body.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
if provider_id == "rotation":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
selected_provider, selected_model = _rotation_handler._select_provider_and_model(actual_model)
provider_id = selected_provider
actual_model = selected_model
elif provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
autoselect_config = _config.autoselect[actual_model]
fallback = autoselect_config.fallback
if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1)
else:
if fallback in _config.rotations:
selected_provider, selected_model = _rotation_handler._select_provider_and_model(fallback)
provider_id = selected_provider
actual_model = selected_model
else:
raise HTTPException(status_code=400, detail=f"Invalid fallback configuration for autoselect '{actual_model}'")
if provider_id not in _config.providers:
raise HTTPException(status_code=400, detail=f"Provider '{provider_id}' not found. Available: {list(_config.providers.keys())}")
body['model'] = actual_model
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
return await handler.handle_image_generation(request, provider_id, body)
@router.post("/api/v1/embeddings")
async def v1_embeddings(request: Request, body: dict):
logger.info("=== V1 EMBEDDINGS REQUEST ===")
model = body.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
if provider_id == "rotation":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
selected_provider, selected_model = _rotation_handler._select_provider_and_model(actual_model)
provider_id = selected_provider
actual_model = selected_model
elif provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
autoselect_config = _config.autoselect[actual_model]
fallback = autoselect_config.fallback
if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1)
else:
if fallback in _config.rotations:
selected_provider, selected_model = _rotation_handler._select_provider_and_model(fallback)
provider_id = selected_provider
actual_model = selected_model
else:
raise HTTPException(status_code=400, detail=f"Invalid fallback configuration for autoselect '{actual_model}'")
if provider_id not in _config.providers:
raise HTTPException(status_code=400, detail=f"Provider '{provider_id}' not found. Available: {list(_config.providers.keys())}")
body['model'] = actual_model
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
return await handler.handle_embeddings(request, provider_id, body)
@router.get("/api/rotations")
async def list_rotations():
logger.info("=== LIST ROTATIONS REQUEST ===")
rotations_info = {}
for rotation_id, rotation_config in _config.rotations.items():
models = []
for provider in rotation_config.providers:
for model in provider['models']:
models.append({"name": model['name'], "provider_id": provider['provider_id'], "weight": model['weight'], "rate_limit": model.get('rate_limit')})
rotations_info[rotation_id] = {"model_name": rotation_config.model_name, "models": models}
return rotations_info
@router.post("/api/rotations/chat/completions")
async def rotation_chat_completions(request: Request, body: ChatCompletionRequest):
logger.info(f"=== ROTATION CHAT COMPLETION REQUEST START ===")
body_dict = body.model_dump()
if body.model not in _config.rotations:
raise HTTPException(status_code=404, detail=f"Rotation '{body.model}' not found. Available: {list(_config.rotations.keys())}")
try:
user_id = getattr(request.state, 'user_id', None)
token_id = getattr(request.state, 'token_id', None)
handler = _get_user_handler('rotation', user_id)
return await handler.handle_rotation_request(body.model, body_dict, user_id, token_id)
except Exception as e:
logger.error(f"Error handling rotation chat_completions: {str(e)}", exc_info=True)
raise
@router.get("/api/rotations/models")
async def list_rotation_models():
logger.info("=== LIST ROTATION MODELS REQUEST ===")
all_models = []
for rotation_id, rotation_config in _config.rotations.items():
for provider in rotation_config.providers:
for model in provider['models']:
all_models.append({"id": f"{rotation_id}/{model['name']}", "name": rotation_id, "object": "model", "created": int(time.time()), "owned_by": provider['provider_id'], "rotation_id": rotation_id, "actual_model": model['name'], "provider_id": provider['provider_id'], "weight": model['weight'], "rate_limit": model.get('rate_limit')})
return {"data": all_models}
@router.get("/api/autoselect")
async def list_autoselect():
logger.info("=== LIST AUTOSELECT REQUEST ===")
autoselect_info = {}
for autoselect_id, autoselect_config in _config.autoselect.items():
autoselect_info[autoselect_id] = {"model_name": autoselect_config.model_name, "description": autoselect_config.description, "fallback": autoselect_config.fallback, "available_models": [{"model_id": m.model_id, "description": m.description} for m in autoselect_config.available_models]}
return autoselect_info
@router.post("/api/autoselect/chat/completions")
async def autoselect_chat_completions(request: Request, body: ChatCompletionRequest):
logger.info(f"=== AUTOSELECT CHAT COMPLETION REQUEST START ===")
body_dict = body.model_dump()
user_id = getattr(request.state, 'user_id', None)
token_id = getattr(request.state, 'token_id', None)
handler = _get_user_handler('autoselect', user_id)
if body.model not in _config.autoselect and (not user_id or body.model not in handler.user_autoselects):
raise HTTPException(status_code=400, detail=f"Model '{body.model}' not found. Available autoselect: {list(_config.autoselect.keys())}")
try:
if body.stream:
return await handler.handle_autoselect_streaming_request(body.model, body_dict)
else:
return await handler.handle_autoselect_request(body.model, body_dict, user_id, token_id)
except Exception as e:
logger.error(f"Error handling autoselect chat_completions: {str(e)}", exc_info=True)
raise
@router.get("/api/autoselect/models")
async def list_autoselect_models():
logger.info("=== LIST AUTOSELECT MODELS REQUEST ===")
all_models = []
for autoselect_id, autoselect_config in _config.autoselect.items():
for model_info in autoselect_config.available_models:
all_models.append({"id": model_info.model_id, "name": autoselect_id, "object": "model", "created": int(time.time()), "owned_by": "autoselect", "autoselect_id": autoselect_id, "description": model_info.description, "fallback": autoselect_config.fallback})
return {"data": all_models}
@router.get("/api/autoselections/models")
async def list_autoselection_models():
return await list_autoselect_models()
@router.post("/api/{provider_id}/chat/completions")
async def provider_chat_completions(request: Request, provider_id: str, body: dict):
logger.info(f"=== PROVIDER CHAT COMPLETIONS REQUEST === Provider ID: {provider_id}")
model = body.get('model', '')
if '/' not in model:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
actual_model = model.split('/', 1)[1]
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
body_dict = dict(body)
body_dict['model'] = actual_model
if provider_id not in _config.providers and (not user_id or provider_id not in handler.user_providers):
raise HTTPException(status_code=400, detail=f"Provider {provider_id} not found")
try:
if body.get('stream'):
result = await handler.handle_streaming_chat_completion(request, provider_id, body_dict)
else:
result = await handler.handle_chat_completion(request, provider_id, body_dict)
import asyncio as _asyncio
_t = _asyncio.create_task(_refresh_provider_usage_if_stale(provider_id, user_id))
_background_tasks.add(_t)
_t.add_done_callback(_background_tasks.discard)
return result
except Exception as e:
logger.error(f"Error handling chat_completions: {str(e)}", exc_info=True)
raise
@router.get("/api/{provider_id}/models")
async def list_models(request: Request, provider_id: str):
logger.debug(f"Received list_models request for provider: {provider_id}")
AISBF_DEBUG = os.environ.get('AISBF_DEBUG', '').lower() in ('true', '1', 'yes')
user_id = getattr(request.state, 'user_id', None)
if provider_id in _config.autoselect or (user_id and provider_id in _get_user_handler('autoselect', user_id).user_autoselects):
handler = _get_user_handler('autoselect', user_id)
try:
return await handler.handle_autoselect_model_list(provider_id)
except Exception as e:
logger.error(f"Error handling autoselect model list: {str(e)}", exc_info=True)
raise
if provider_id in _config.rotations or (user_id and provider_id in _get_user_handler('rotation', user_id).rotations):
handler = _get_user_handler('rotation', user_id)
return await handler.handle_rotation_model_list(provider_id)
handler = _get_user_handler('request', user_id)
if provider_id not in _config.providers and (not user_id or provider_id not in handler.user_providers):
raise HTTPException(status_code=400, detail=f"Provider {provider_id} not found")
try:
return await handler.handle_model_list(request, provider_id)
except Exception as e:
logger.error(f"Error handling list_models: {str(e)}", exc_info=True)
raise
@router.post("/api/audio/transcriptions")
async def audio_transcriptions(request: Request):
logger.info("=== AUDIO TRANSCRIPTION REQUEST ===")
form = await request.form()
model = form.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
if provider_id == "rotation":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
selected_provider, selected_model = _rotation_handler._select_provider_and_model(actual_model)
provider_id = selected_provider
actual_model = selected_model
elif provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
autoselect_config = _config.autoselect[actual_model]
fallback = autoselect_config.fallback
if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1)
else:
if fallback in _config.rotations:
selected_provider, selected_model = _rotation_handler._select_provider_and_model(fallback)
provider_id = selected_provider
actual_model = selected_model
else:
raise HTTPException(status_code=400, detail=f"Invalid fallback configuration for autoselect '{actual_model}'")
if provider_id not in _config.providers:
raise HTTPException(status_code=400, detail=f"Provider '{provider_id}' not found. Available: {list(_config.providers.keys())}")
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
from starlette.datastructures import FormData
updated_form = FormData()
for key, value in form.items():
updated_form[key] = actual_model if key == 'model' else value
return await handler.handle_audio_transcription(request, provider_id, updated_form)
@router.post("/api/audio/speech")
async def audio_speech(request: Request, body: dict):
logger.info("=== TEXT-TO-SPEECH REQUEST ===")
model = body.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
if provider_id == "rotation":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
selected_provider, selected_model = _rotation_handler._select_provider_and_model(actual_model)
provider_id = selected_provider
actual_model = selected_model
elif provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
autoselect_config = _config.autoselect[actual_model]
fallback = autoselect_config.fallback
if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1)
else:
if fallback in _config.rotations:
selected_provider, selected_model = _rotation_handler._select_provider_and_model(fallback)
provider_id = selected_provider
actual_model = selected_model
else:
raise HTTPException(status_code=400, detail=f"Invalid fallback configuration for autoselect '{actual_model}'")
if provider_id not in _config.providers:
raise HTTPException(status_code=400, detail=f"Provider '{provider_id}' not found. Available: {list(_config.providers.keys())}")
body['model'] = actual_model
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
return await handler.handle_text_to_speech(request, provider_id, body)
@router.post("/api/images/generations")
async def image_generations(request: Request, body: dict):
logger.info("=== IMAGE GENERATION REQUEST ===")
model = body.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
if provider_id == "rotation":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
selected_provider, selected_model = _rotation_handler._select_provider_and_model(actual_model)
provider_id = selected_provider
actual_model = selected_model
elif provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
autoselect_config = _config.autoselect[actual_model]
fallback = autoselect_config.fallback
if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1)
else:
if fallback in _config.rotations:
selected_provider, selected_model = _rotation_handler._select_provider_and_model(fallback)
provider_id = selected_provider
actual_model = selected_model
else:
raise HTTPException(status_code=400, detail=f"Invalid fallback configuration for autoselect '{actual_model}'")
if provider_id not in _config.providers:
raise HTTPException(status_code=400, detail=f"Provider '{provider_id}' not found. Available: {list(_config.providers.keys())}")
body['model'] = actual_model
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
return await handler.handle_image_generation(request, provider_id, body)
@router.post("/api/embeddings")
async def embeddings(request: Request, body: dict):
logger.info("=== EMBEDDINGS REQUEST ===")
model = body.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
if provider_id == "rotation":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
selected_provider, selected_model = _rotation_handler._select_provider_and_model(actual_model)
provider_id = selected_provider
actual_model = selected_model
elif provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
autoselect_config = _config.autoselect[actual_model]
fallback = autoselect_config.fallback
if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1)
else:
if fallback in _config.rotations:
selected_provider, selected_model = _rotation_handler._select_provider_and_model(fallback)
provider_id = selected_provider
actual_model = selected_model
else:
raise HTTPException(status_code=400, detail=f"Invalid fallback configuration for autoselect '{actual_model}'")
if provider_id not in _config.providers:
raise HTTPException(status_code=400, detail=f"Provider '{provider_id}' not found. Available: {list(_config.providers.keys())}")
body['model'] = actual_model
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
return await handler.handle_embeddings(request, provider_id, body)
@router.post("/api/{provider_id}")
async def catch_all_post(provider_id: str, request: Request):
logger.info(f"=== CATCH-ALL POST REQUEST === path: {request.url.path}, provider: {provider_id}")
error_msg = f"Invalid endpoint: {request.url.path}\n\nThe correct endpoint format is: /api/{{provider_id}}/chat/completions\n\nAvailable providers: {list(_config.providers.keys())}\nAvailable rotations: {list(_config.rotations.keys())}\nAvailable autoselect: {list(_config.autoselect.keys())}\n\nExample: POST /api/ollama/chat/completions"
raise HTTPException(status_code=404, detail=error_msg.strip())
@router.get("/api/proxy/{content_id}")
async def proxy_content(content_id: str):
"""Proxy generated content (images, audio, etc.)"""
from fastapi import HTTPException
try:
result = await _request_handler.handle_content_proxy(content_id)
return result
except Exception as e:
logging.getLogger(__name__).error(f"Error proxying content: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
from fastapi import APIRouter, Request, Form, Query, UploadFile, File, HTTPException
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, Response
from typing import Optional
import time, logging, secrets, hashlib, os, re
from pathlib import Path
from datetime import datetime, timedelta
from aisbf.database import DatabaseRegistry
from aisbf.database import _hash_password as _db_hash_password, _verify_password as _db_verify_password
from aisbf.app.startup import _login_rate_limit_check, _login_record_failure, _login_clear_failures, _send_admin_notification_email
from aisbf.app.templates import url_for, get_base_url
router = APIRouter()
_config = None
_templates = None
_server_config = None
_DEFAULT_ADMIN_SHA256 = '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918'
_MUST_CHANGE_PASSWORD_WHITELIST = (
'/dashboard/settings', '/dashboard/logout', '/api/admin/settings/',
'/dashboard/tor/status', '/dashboard/response-cache/stats',
'/dashboard/response-cache/clear', '/dashboard/local-models/clear-cache',
'/dashboard/test-smtp', '/dashboard/restart',
)
def init(config, templates, server_config):
global _config, _templates, _server_config
_config = config
_templates = templates
_server_config = server_config
logger = logging.getLogger(__name__)
@router.get("/dashboard/profile-pic")
async def dashboard_profile_pic(request: Request):
"""Serve the logged-in user's profile picture from the database."""
user_id = request.session.get('user_id')
if not user_id:
return Response(status_code=404)
try:
db = DatabaseRegistry.get_config_database()
user = db.get_user_by_id(user_id)
pic = user.get('profile_pic') if user else None
if not pic:
return Response(status_code=404)
if pic.startswith('data:'):
header, b64data = pic.split(',', 1)
mime = header.split(':')[1].split(';')[0]
import base64
img_bytes = base64.b64decode(b64data)
return Response(content=img_bytes, media_type=mime,
headers={"Cache-Control": "private, max-age=3600"})
return RedirectResponse(url=pic)
except Exception:
return Response(status_code=404)
@router.get("/dashboard/login", response_class=HTMLResponse)
async def dashboard_login_page(request: Request):
"""Show dashboard login page"""
try:
signup_enabled = False
if _config and hasattr(_config, 'aisbf') and _config.aisbf:
signup_enabled = getattr(_config.aisbf.signup, 'enabled', False) if _config.aisbf.signup else False
smtp_enabled = False
if _config and hasattr(_config, 'aisbf') and _config.aisbf and hasattr(_config.aisbf, 'smtp') and _config.aisbf.smtp:
smtp_enabled = getattr(_config.aisbf.smtp, 'enabled', False)
show_verify_email = request.query_params.get('signup') == 'success' and smtp_enabled
error_message = request.query_params.get('error')
success_message = request.query_params.get('success')
is_cloud = request.url.hostname == 'aisbf.cloud' or request.url.hostname.endswith('.aisbf.cloud')
is_onion = request.url.hostname == 'aisbfity4ud6nsht53tsh2iauaur2e4dah2gplcprnikyjpkg72vfjad.onion'
is_aisbf_cloud = is_cloud or is_onion
template = _templates.get_template("dashboard/login.html")
html_content = template.render(
request=request,
signup_enabled=signup_enabled,
smtp_enabled=smtp_enabled,
show_verify_email=show_verify_email,
error=error_message,
success=success_message,
config=_config.aisbf if _config and _config.aisbf else {},
is_aisbf_cloud=is_aisbf_cloud,
welcome_shown=True
)
return HTMLResponse(content=html_content)
except Exception as e:
logger.error(f"Error rendering login page: {e}", exc_info=True)
raise
@router.get("/auth/logincheck")
async def auth_logincheck(request: Request):
"""Serve JavaScript that redirects to dashboard if user is logged in"""
is_logged_in = request.session.get('logged_in', False)
if is_logged_in:
expires_at = request.session.get('expires_at')
if expires_at and int(time.time()) > expires_at:
is_logged_in = False
if is_logged_in:
root_path = request.scope.get("root_path", "")
dashboard_path = f"{root_path}/dashboard"
js_content = f"""
(function() {{
if (window.location.pathname !== '{dashboard_path}') {{
window.location.href = '{dashboard_path}';
}}
}})();
"""
else:
js_content = """
(function() {
// User not logged in, do nothing
})();
"""
return Response(
content=js_content,
media_type="application/javascript",
headers={"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0"}
)
@router.post("/dashboard/login")
async def dashboard_login(request: Request, username: str = Form(...), password: str = Form(...), remember_me: bool = Form(False)):
"""Handle dashboard login"""
client_ip = request.client.host if request.client else "unknown"
if _login_rate_limit_check(client_ip, username):
return RedirectResponse(
url=url_for(request, "/dashboard/login") + "?error=Too+many+failed+attempts.+Please+wait+and+try+again.",
status_code=303
)
db = DatabaseRegistry.get_config_database()
user = db.authenticate_user(username, password)
if user:
logger.info(f"User authenticated: username={username}, email={user.get('email')}, user_id={user['id']}")
request.session['logged_in'] = True
_login_clear_failures(client_ip, username)
request.session['username'] = username
request.session['display_name'] = user.get('display_name') or ''
request.session['email'] = user.get('email') or ''
request.session['role'] = user['role']
request.session['user_id'] = user['id']
request.session['has_profile_pic'] = bool(user.get('profile_pic'))
request.session['remember_me'] = remember_me
request.session['email_verified'] = user['email_verified']
if remember_me:
request.session['expires_at'] = int(time.time()) + 30 * 24 * 60 * 60
else:
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
with db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if db.db_type == 'sqlite' else '%s'
cursor.execute(f'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = {placeholder}', (user['id'],))
conn.commit()
if not user['email_verified']:
if user['created_at']:
if isinstance(user['created_at'], str):
created_at = datetime.fromisoformat(user['created_at'])
else:
created_at = user['created_at']
else:
created_at = datetime.now()
if datetime.now() - created_at > timedelta(hours=24):
db.delete_user(user['id'])
return _templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={
"request": request,
"error": "Your account verification has expired. Please sign up again.",
"config": _config.aisbf if _config and _config.aisbf else {}
}
)
else:
return RedirectResponse(url=url_for(request, "/dashboard/verify"), status_code=303)
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
dashboard_config = _server_config.get('dashboard_config', {}) if _server_config else {}
stored_username = dashboard_config.get('username', 'admin')
stored_password_hash = dashboard_config.get('password', '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918')
if username == stored_username and _db_verify_password(password, stored_password_hash):
_login_clear_failures(client_ip, username)
request.session['logged_in'] = True
request.session['username'] = username
request.session['role'] = 'admin'
request.session['user_id'] = None
request.session['remember_me'] = remember_me
request.session['must_change_password'] = (stored_password_hash == _DEFAULT_ADMIN_SHA256)
if remember_me:
request.session['expires_at'] = int(time.time()) + 30 * 24 * 60 * 60
else:
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
if request.session['must_change_password']:
return RedirectResponse(
url=url_for(request, "/dashboard/settings") + "?warning=default_password",
status_code=303
)
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
_login_record_failure(client_ip, username)
return RedirectResponse(url=url_for(request, "/dashboard/login") + "?error=Invalid username or password", status_code=303)
@router.get("/dashboard/signup", response_class=HTMLResponse)
async def dashboard_signup_page(request: Request):
"""Show dashboard signup page"""
try:
signup_enabled = False
if _config and hasattr(_config, 'aisbf') and _config.aisbf:
signup_enabled = getattr(_config.aisbf.signup, 'enabled', False) if _config.aisbf.signup else False
if not signup_enabled:
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
is_cloud = request.url.hostname == 'aisbf.cloud' or request.url.hostname.endswith('.aisbf.cloud')
is_onion = request.url.hostname == 'aisbfity4ud6nsht53tsh2iauaur2e4dah2gplcprnikyjpkg72vfjad.onion'
is_aisbf_cloud = is_cloud or is_onion
template = _templates.get_template("dashboard/signup.html")
html_content = template.render(
request=request,
config=_config.aisbf if _config and _config.aisbf else {},
is_aisbf_cloud=is_aisbf_cloud,
welcome_shown=True
)
return HTMLResponse(content=html_content)
except Exception as e:
logger.error(f"Error rendering signup page: {e}", exc_info=True)
raise
@router.post("/dashboard/signup")
async def dashboard_signup(
request: Request,
username: str = Form(...),
email: str = Form(...),
password: str = Form(...),
confirm_password: str = Form(...)
):
"""Handle user signup"""
from aisbf.email_utils import hash_password, generate_verification_token, send_verification_email
signup_enabled = False
if _config and hasattr(_config, 'aisbf') and _config.aisbf:
signup_enabled = getattr(_config.aisbf.signup, 'enabled', False) if _config.aisbf.signup else False
if not signup_enabled:
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
if not re.match(r"^[a-zA-Z0-9_.-]+$", username):
return _templates.TemplateResponse(request=request, name="dashboard/signup.html",
context={"request": request, "error": "Username can only contain letters, numbers, underscores, hyphens, and dots",
"config": _config.aisbf if _config and _config.aisbf else {}})
if len(username) < 3 or len(username) > 50:
return _templates.TemplateResponse(request=request, name="dashboard/signup.html",
context={"request": request, "error": "Username must be between 3 and 50 characters",
"config": _config.aisbf if _config and _config.aisbf else {}})
if password != confirm_password:
return _templates.TemplateResponse(request=request, name="dashboard/signup.html",
context={"request": request, "error": "Passwords do not match",
"config": _config.aisbf if _config and _config.aisbf else {}})
if len(password) < 8:
return _templates.TemplateResponse(request=request, name="dashboard/signup.html",
context={"request": request, "error": "Password must be at least 8 characters long",
"config": _config.aisbf if _config and _config.aisbf else {}})
if not re.match(r"[^@]+@[^@]+\.[^@]+", email):
return _templates.TemplateResponse(request=request, name="dashboard/signup.html",
context={"request": request, "error": "Invalid email address",
"config": _config.aisbf if _config and _config.aisbf else {}})
try:
db = DatabaseRegistry.get_config_database()
existing_user_by_username = db.get_user_by_username(username)
if existing_user_by_username:
return _templates.TemplateResponse(request=request, name="dashboard/signup.html",
context={"request": request, "error": "This username is already taken. Please choose a different one.",
"config": _config.aisbf if _config and _config.aisbf else {}})
except Exception as e:
logger.error(f"Error checking username uniqueness: {e}", exc_info=True)
return _templates.TemplateResponse(request=request, name="dashboard/signup.html",
context={"request": request, "error": "An error occurred during signup. Please try again.",
"config": _config.aisbf if _config and _config.aisbf else {}})
try:
db = DatabaseRegistry.get_config_database()
existing_user = db.get_user_by_email(email)
if existing_user:
if existing_user['email_verified']:
return _templates.TemplateResponse(request=request, name="dashboard/signup.html",
context={"request": request, "error": "An account with this email already exists",
"config": _config.aisbf if _config and _config.aisbf else {}})
else:
verification_token = generate_verification_token()
expires_at = datetime.now() + timedelta(hours=24)
db.set_verification_token(existing_user['id'], verification_token, expires_at)
db.update_last_verification_email_sent(existing_user['id'], datetime.now())
try:
base_url = get_base_url(request)
send_verification_email(email, email, verification_token, base_url, _config.aisbf.smtp if _config.aisbf.smtp else None)
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
except Exception as e:
logger.error(f"Failed to send verification email: {e}")
return _templates.TemplateResponse(request=request, name="dashboard/signup.html",
context={"request": request, "message": "Account already exists but not verified. A new verification email has been sent.",
"config": _config.aisbf if _config and _config.aisbf else {}})
password_hash = hash_password(password)
verification_token = generate_verification_token()
user_id = db.create_user(username=username, password_hash=password_hash, role='user', email=email, email_verified=False)
_send_admin_notification_email(
_config,
'new_user_signup',
f"New user signup: {username}",
f"<h2>New User Signup</h2><p>A new user has registered on your AISBF instance.</p>"
f"<ul><li><b>Username:</b> {username}</li><li><b>Email:</b> {email or '(none)'}</li></ul>"
)
expires_at = datetime.now() + timedelta(hours=24)
db.set_verification_token(user_id, verification_token, expires_at)
db.update_last_verification_email_sent(user_id, datetime.now())
try:
base_url = get_base_url(request)
send_verification_email(email, email, verification_token, base_url, _config.aisbf.smtp if _config.aisbf.smtp else None)
return RedirectResponse(url=url_for(request, "/dashboard/login") + "?signup=success", status_code=303)
except Exception as e:
logger.error(f"Failed to send verification email: {e}")
return _templates.TemplateResponse(request=request, name="dashboard/signup.html",
context={"request": request, "message": "Account created successfully! However, there was an issue sending the verification email. Please contact an administrator.",
"config": _config.aisbf if _config and _config.aisbf else {}})
except Exception as e:
logger.error(f"Error during signup: {e}", exc_info=True)
return _templates.TemplateResponse(request=request, name="dashboard/signup.html",
context={"request": request, "error": "An error occurred during signup. Please try again.",
"config": _config.aisbf if _config and _config.aisbf else {}})
@router.get("/dashboard/verify")
async def verify_email_page(request: Request):
"""Show email verification page"""
if not request.session.get('logged_in'):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
user_id = request.session.get('user_id')
if not user_id:
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
db = DatabaseRegistry.get_config_database()
user = db.get_user_by_id(user_id)
if not user or user['email_verified']:
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
can_resend = True
if user.get('last_verification_email_sent'):
if isinstance(user['last_verification_email_sent'], str):
last_sent = datetime.fromisoformat(user['last_verification_email_sent'])
else:
last_sent = user['last_verification_email_sent']
if datetime.now() - last_sent < timedelta(minutes=10):
can_resend = False
return _templates.TemplateResponse(request=request, name="dashboard/verify.html",
context={"request": request, "user": user, "can_resend": can_resend,
"config": _config.aisbf if _config and _config.aisbf else {}})
@router.post("/dashboard/resend-verification")
async def resend_verification(request: Request):
"""Resend verification email"""
from aisbf.email_utils import send_verification_email, generate_verification_token
if not request.session.get('logged_in'):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
user_id = request.session.get('user_id')
if not user_id:
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
db = DatabaseRegistry.get_config_database()
user = db.get_user_by_id(user_id)
if not user or user['email_verified']:
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
if user.get('last_verification_email_sent'):
if isinstance(user['last_verification_email_sent'], str):
last_sent = datetime.fromisoformat(user['last_verification_email_sent'])
else:
last_sent = user['last_verification_email_sent']
if datetime.now() - last_sent < timedelta(minutes=10):
return _templates.TemplateResponse(request=request, name="dashboard/verify.html",
context={"request": request, "user": user, "can_resend": False,
"error": "Please wait 10 minutes before requesting another verification email.",
"config": _config.aisbf if _config and _config.aisbf else {}})
verification_token = generate_verification_token()
expires_at = datetime.now() + timedelta(hours=24)
db.set_verification_token(user_id, verification_token, expires_at)
db.update_last_verification_email_sent(user_id, datetime.now())
try:
base_url = get_base_url(request)
send_verification_email(user['email'], user['username'], verification_token, base_url, _config.aisbf.smtp if _config.aisbf.smtp else None)
message = "Verification email sent successfully!"
except Exception as e:
logger.error(f"Failed to send verification email: {e}")
message = "Failed to send verification email. Please try again later."
return _templates.TemplateResponse(request=request, name="dashboard/verify.html",
context={"request": request, "user": user, "can_resend": False, "message": message,
"config": _config.aisbf if _config and _config.aisbf else {}})
@router.get("/dashboard/verify-email")
async def verify_email(request: Request, token: str, email: str):
"""Handle email verification"""
try:
db = DatabaseRegistry.get_config_database()
if db.verify_email_token(email, token):
db.verify_email(email)
if request.session.get('logged_in'):
request.session['email_verified'] = True
return RedirectResponse(url=url_for(request, "/dashboard/login") + "?success=Email verified successfully! You can now log in.", status_code=303)
else:
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "error": "Invalid or expired verification token",
"config": _config.aisbf if _config and _config.aisbf else {}})
except Exception as e:
logger.error(f"Error during email verification: {e}", exc_info=True)
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "error": "An error occurred during email verification",
"config": _config.aisbf if _config and _config.aisbf else {}})
@router.get("/dashboard/forgot-password", response_class=HTMLResponse)
async def dashboard_forgot_password_page(request: Request):
"""Show forgot password page"""
try:
smtp_enabled = False
if _config and hasattr(_config, 'aisbf') and _config.aisbf and hasattr(_config.aisbf, 'smtp'):
smtp_enabled = getattr(_config.aisbf.smtp, 'enabled', False)
if not smtp_enabled:
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
is_cloud = request.url.hostname == 'aisbf.cloud' or request.url.hostname.endswith('.aisbf.cloud')
is_onion = request.url.hostname == 'aisbfity4ud6nsht53tsh2iauaur2e4dah2gplcprnikyjpkg72vfjad.onion'
is_aisbf_cloud = is_cloud or is_onion
template = _templates.get_template("dashboard/forgot_password.html")
html_content = template.render(
request=request,
config=_config.aisbf if _config and _config.aisbf else {},
is_aisbf_cloud=is_aisbf_cloud,
welcome_shown=True
)
return HTMLResponse(content=html_content)
except Exception as e:
logger.error(f"Error rendering forgot password page: {e}", exc_info=True)
raise
@router.post("/dashboard/forgot-password")
async def dashboard_forgot_password(request: Request, email: str = Form(...)):
"""Handle forgot password request"""
from aisbf.email_utils import generate_password_reset_token, send_password_reset_email
smtp_enabled = False
if _config and hasattr(_config, 'aisbf') and _config.aisbf and hasattr(_config.aisbf, 'smtp'):
smtp_enabled = getattr(_config.aisbf.smtp, 'enabled', False)
if not smtp_enabled:
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
try:
db = DatabaseRegistry.get_config_database()
user = db.get_user_by_email(email)
if user and user['email_verified']:
reset_token = generate_password_reset_token()
expires_at = datetime.now() + timedelta(hours=1)
db.set_password_reset_token(user['id'], reset_token, expires_at)
try:
base_url = get_base_url(request)
success = send_password_reset_email(
to_email=email,
username=user.get('username', email),
reset_token=reset_token,
base_url=base_url,
smtp_config=_config.aisbf.smtp
)
if not success:
logger.error(f"Failed to send password reset email to {email}")
except Exception as e:
logger.error(f"Failed to send password reset email: {e}")
return _templates.TemplateResponse(request=request, name="dashboard/forgot_password.html",
context={"request": request, "success": True,
"message": "If an account exists with that email address, we have sent a password reset link.",
"message_type": "success"})
except Exception as e:
logger.error(f"Error processing forgot password request: {e}", exc_info=True)
return _templates.TemplateResponse(request=request, name="dashboard/forgot_password.html",
context={"request": request, "error": "An error occurred processing your request. Please try again later.",
"config": _config.aisbf if _config and _config.aisbf else {}})
@router.get("/dashboard/reset-password", response_class=HTMLResponse)
async def dashboard_reset_password_page(request: Request, token: str = Query(...), email: str = Query(...)):
"""Show password reset page"""
try:
db = DatabaseRegistry.get_config_database()
token_valid = db.validate_password_reset_token(email, token)
if not token_valid:
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "error": "Invalid or expired password reset token. Please request a new one.",
"config": _config.aisbf if _config and _config.aisbf else {}})
is_cloud = request.url.hostname == 'aisbf.cloud' or request.url.hostname.endswith('.aisbf.cloud')
is_onion = request.url.hostname == 'aisbfity4ud6nsht53tsh2iauaur2e4dah2gplcprnikyjpkg72vfjad.onion'
is_aisbf_cloud = is_cloud or is_onion
template = _templates.get_template("dashboard/reset_password.html")
html_content = template.render(
request=request,
email=email,
token=token,
config=_config.aisbf if _config and _config.aisbf else {},
is_aisbf_cloud=is_aisbf_cloud,
welcome_shown=True
)
return HTMLResponse(content=html_content)
except Exception as e:
logger.error(f"Error rendering reset password page: {e}", exc_info=True)
raise
@router.post("/dashboard/reset-password")
async def dashboard_reset_password(
request: Request,
email: str = Form(...),
token: str = Form(...),
password: str = Form(...),
confirm_password: str = Form(...)
):
"""Handle password reset confirmation"""
from aisbf.email_utils import hash_password
try:
db = DatabaseRegistry.get_config_database()
reset_user = db.get_user_by_reset_token(token)
if not reset_user or reset_user.get('email', '').lower() != email.lower():
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "error": "Invalid or expired password reset token. Please request a new one.",
"config": _config.aisbf if _config and _config.aisbf else {}})
if password != confirm_password:
return _templates.TemplateResponse(request=request, name="dashboard/reset_password.html",
context={"request": request, "email": email, "token": token, "error": "Passwords do not match",
"config": _config.aisbf if _config and _config.aisbf else {}})
if len(password) < 8:
return _templates.TemplateResponse(request=request, name="dashboard/reset_password.html",
context={"request": request, "email": email, "token": token,
"error": "Password must be at least 8 characters long",
"config": _config.aisbf if _config and _config.aisbf else {}})
password_hash = hash_password(password)
user_id = reset_user['id']
db.update_user_password(user_id, password_hash)
db.clear_password_reset_token(user_id)
logger.info(f"Password successfully reset for user {email}")
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request,
"message": "Password has been reset successfully. You can now login with your new password.",
"config": _config.aisbf if _config and _config.aisbf else {}})
except Exception as e:
logger.error(f"Error processing password reset: {e}", exc_info=True)
return _templates.TemplateResponse(request=request, name="dashboard/reset_password.html",
context={"request": request, "email": email, "token": token,
"error": "An error occurred resetting your password. Please try again later.",
"config": _config.aisbf if _config and _config.aisbf else {}})
@router.get("/dashboard/logout")
async def dashboard_logout(request: Request):
"""Handle dashboard logout"""
admin_session = request.session.get('admin_session')
if admin_session:
# Restore admin session after impersonation
request.session.clear()
for k, v in admin_session.items():
request.session[k] = v
return RedirectResponse(url=url_for(request, "/dashboard/users"), status_code=303)
request.session.clear()
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
@router.get("/dashboard/profile", response_class=HTMLResponse)
async def dashboard_profile(request: Request):
"""User profile page"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
user = db.get_user_by_id(user_id)
return _templates.TemplateResponse(request=request, name="dashboard/profile.html",
context={"session": request.session, "user": user,
"success": request.query_params.get('success'),
"error": request.query_params.get('error')})
@router.post("/dashboard/profile")
async def dashboard_profile_save(request: Request, username: str = Form(...), display_name: str = Form("")):
"""Save user profile changes"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
user_id = request.session.get('user_id')
db = DatabaseRegistry.get_config_database()
try:
db.update_user_profile(user_id, username, None, display_name if display_name else None, None)
request.session['username'] = username
request.session['display_name'] = display_name or ''
return RedirectResponse(url=url_for(request, "/dashboard/profile?success=Profile updated successfully"), status_code=303)
except Exception as e:
return RedirectResponse(url=url_for(request, f"/dashboard/profile?error=Failed to update profile: {str(e)}"), status_code=303)
_PROFILE_PIC_MAX_BYTES = 5 * 1024 * 1024 # 5 MB assembled limit
@router.post("/dashboard/profile/upload-pic/chunk")
async def dashboard_profile_pic_chunk(
request: Request,
file_name: str = Form(...),
chunk_number: int = Form(...),
total_chunks: int = Form(...),
total_size: int = Form(...),
file: UploadFile = File(...)
):
"""Chunked profile picture upload."""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Unauthorized"}, status_code=401)
user_id = request.session.get('user_id')
if total_size > _PROFILE_PIC_MAX_BYTES:
return JSONResponse({"success": False, "error": "Image too large. Maximum size is 5 MB."}, status_code=400)
content_type = file.content_type or ''
if not content_type.startswith('image/'):
ext = Path(file_name).suffix.lower()
ext_map = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.webp': 'image/webp'}
content_type = ext_map.get(ext, '')
if not content_type:
return JSONResponse({"success": False, "error": "Invalid file type. Upload JPG, PNG, GIF or WebP."}, status_code=400)
import hashlib as _hl
upload_id = _hl.sha256(f"{user_id}:{file_name}:{total_size}".encode()).hexdigest()[:16]
temp_dir = Path.home() / '.aisbf' / 'temp_uploads' / 'profile_pics'
temp_dir.mkdir(parents=True, exist_ok=True)
chunk_data = await file.read()
chunk_path = temp_dir / f"{upload_id}.part{chunk_number}"
with open(chunk_path, 'wb') as f:
f.write(chunk_data)
received = list(temp_dir.glob(f"{upload_id}.part*"))
if len(received) < total_chunks:
return JSONResponse({"success": True, "complete": False, "chunk": chunk_number})
try:
assembled = bytearray()
for i in range(1, total_chunks + 1):
part = temp_dir / f"{upload_id}.part{i}"
assembled.extend(part.read_bytes())
part.unlink()
if len(assembled) > _PROFILE_PIC_MAX_BYTES:
return JSONResponse({"success": False, "error": "Assembled image exceeds 5 MB limit."}, status_code=400)
import base64 as _b64
data_url = f"data:{content_type};base64,{_b64.b64encode(bytes(assembled)).decode()}"
db = DatabaseRegistry.get_config_database()
db.update_user_profile(user_id, request.session.get('username', ''), None, None, data_url)
request.session['has_profile_pic'] = True
return JSONResponse({"success": True, "complete": True})
except Exception as e:
logger.error(f"Profile pic assembly error for user {user_id}: {e}")
for part in temp_dir.glob(f"{upload_id}.part*"):
try:
part.unlink()
except Exception:
pass
return JSONResponse({"success": False, "error": "Upload failed. Please try again."}, status_code=500)
@router.get("/dashboard/change-password", response_class=HTMLResponse)
async def dashboard_change_password(request: Request):
"""Change user password page"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
return _templates.TemplateResponse(request=request, name="dashboard/change_password.html",
context={"session": request.session,
"success": request.query_params.get('success'),
"error": request.query_params.get('error')})
@router.post("/dashboard/change-password")
async def dashboard_change_password_save(request: Request, current_password: str = Form(...), new_password: str = Form(...), confirm_password: str = Form(...)):
"""Save password change"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
user_id = request.session.get('user_id')
db = DatabaseRegistry.get_config_database()
if new_password != confirm_password:
return RedirectResponse(url=url_for(request, "/dashboard/change-password?error=New passwords do not match"), status_code=303)
if len(new_password) < 6:
return RedirectResponse(url=url_for(request, "/dashboard/change-password?error=New password must be at least 6 characters"), status_code=303)
try:
if not db.verify_user_password(user_id, current_password):
return RedirectResponse(url=url_for(request, "/dashboard/change-password?error=Current password is incorrect"), status_code=303)
db.update_user_password(user_id, new_password)
return RedirectResponse(url=url_for(request, "/dashboard/change-password?success=Password changed successfully"), status_code=303)
except Exception as e:
return RedirectResponse(url=url_for(request, f"/dashboard/change-password?error=Failed to change password: {str(e)}"), status_code=303)
@router.get("/dashboard/change-email", response_class=HTMLResponse)
async def dashboard_change_email(request: Request):
"""Change email page"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
return _templates.TemplateResponse(request=request, name="dashboard/change_email.html",
context={"session": request.session,
"success": request.query_params.get('success'),
"error": request.query_params.get('error')})
@router.post("/dashboard/change-email")
async def dashboard_change_email_save(request: Request, new_email: str = Form(...), password: str = Form(...)):
"""Process email change request"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
from aisbf.email_utils import send_email_verification, hash_password
user_id = request.session.get('user_id')
db = DatabaseRegistry.get_config_database()
try:
if not db.verify_user_password(user_id, password):
return RedirectResponse(url=url_for(request, "/dashboard/change-email?error=Incorrect password"), status_code=303)
existing_user = db.get_user_by_email(new_email)
if existing_user and existing_user['id'] != user_id:
return RedirectResponse(url=url_for(request, "/dashboard/change-email?error=Email address already in use"), status_code=303)
token = secrets.token_urlsafe(32)
expires_at = datetime.now() + timedelta(hours=24)
request.session['pending_email_change'] = {
'new_email': new_email,
'token': token,
'expires_at': expires_at.isoformat()
}
base_url = get_base_url(request)
if _config and _config.aisbf and _config.aisbf.smtp and _config.aisbf.smtp.enabled:
send_email_verification(new_email, f"{base_url}/dashboard/verify-email-change?token={token}&email={new_email}", _config.aisbf.smtp)
return RedirectResponse(
url=url_for(request, "/dashboard/change-email?success=Verification email sent to new address. Please check your inbox."),
status_code=303
)
else:
return RedirectResponse(
url=url_for(request, "/dashboard/change-email?error=Email service not configured. Please contact administrator."),
status_code=303
)
except Exception as e:
logger.error(f"Email change error: {e}")
return RedirectResponse(url=url_for(request, f"/dashboard/change-email?error=Failed to process email change: {str(e)}"), status_code=303)
@router.get("/dashboard/verify-email-change")
async def verify_email_change(request: Request, token: str = Query(...), email: str = Query(...)):
"""Verify new email address"""
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
if not user_id:
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
try:
pending = request.session.get('pending_email_change', {})
if not pending or pending.get('token') != token or pending.get('new_email') != email:
return _templates.TemplateResponse(request=request, name="dashboard/change_email.html",
context={"session": request.session, "error": "Invalid or expired verification link"})
expires_at = datetime.fromisoformat(pending['expires_at'])
if datetime.now() > expires_at:
return _templates.TemplateResponse(request=request, name="dashboard/change_email.html",
context={"session": request.session, "error": "Verification link has expired"})
db.update_user_email(user_id, email)
request.session['email'] = email
request.session.pop('pending_email_change', None)
return RedirectResponse(url=url_for(request, "/dashboard/profile?success=Email address updated successfully"), status_code=303)
except Exception as e:
logger.error(f"Email verification error: {e}")
return _templates.TemplateResponse(request=request, name="dashboard/change_email.html",
context={"session": request.session, "error": f"Failed to verify email: {str(e)}"})
@router.get("/dashboard/delete-account", response_class=HTMLResponse)
async def dashboard_delete_account(request: Request):
"""Delete account confirmation page"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
user_id = request.session.get('user_id')
db = DatabaseRegistry.get_config_database()
subscription = db.get_user_subscription(user_id)
has_subscription = subscription is not None and subscription.get('status') == 'active'
subscription_tier = subscription.get('tier_name', '') if subscription else ''
return _templates.TemplateResponse(request=request, name="dashboard/delete_account.html",
context={"session": request.session, "error": request.query_params.get('error'),
"has_subscription": has_subscription, "subscription_tier": subscription_tier})
@router.post("/dashboard/delete-account")
async def dashboard_delete_account_confirm(request: Request, password: str = Form(...), confirmation: str = Form(...)):
"""Process account deletion"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
user_id = request.session.get('user_id')
db = DatabaseRegistry.get_config_database()
try:
if confirmation != "DELETE":
return RedirectResponse(url=url_for(request, "/dashboard/delete-account?error=Please type DELETE to confirm"), status_code=303)
if not db.verify_user_password(user_id, password):
return RedirectResponse(url=url_for(request, "/dashboard/delete-account?error=Incorrect password"), status_code=303)
username = request.session.get('username', f'user #{user_id}')
db.delete_user(user_id)
_send_admin_notification_email(
_config,
'user_deleted_account',
f"User deleted account: {username}",
f"<h2>User Deleted Account</h2><p>User <b>{username}</b> (ID {user_id}) has deleted their account.</p>"
)
request.session.clear()
return RedirectResponse(url=url_for(request, "/dashboard/login?message=Account deleted successfully"), status_code=303)
except Exception as e:
logger.error(f"Account deletion error: {e}")
return RedirectResponse(url=url_for(request, f"/dashboard/delete-account?error=Failed to delete account: {str(e)}"), status_code=303)
# ==============================================
# OAuth2 Authentication Endpoints (Google + GitHub)
# ==============================================
_oauth2_instances = {}
@router.get("/auth/oauth2/google")
async def oauth2_google_initiate(request: Request):
"""Initiate Google OAuth2 authentication flow"""
if not (_config and _config.aisbf and _config.aisbf.oauth2 and
_config.aisbf.oauth2.google and _config.aisbf.oauth2.google.enabled):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
try:
from aisbf.auth.google import GoogleOAuth2
client_id = _config.aisbf.oauth2.google.client_id
client_secret = _config.aisbf.oauth2.google.client_secret
base_url = get_base_url(request)
redirect_uri = f"{base_url}/auth/oauth2/google/callback"
oauth = GoogleOAuth2(client_id, client_secret, redirect_uri)
auth_url = oauth.get_authorization_url(_config.aisbf.oauth2.google.scopes)
request.session['oauth2_google'] = {
'state': oauth._state,
'code_verifier': oauth._code_verifier
}
referer = request.headers.get('Referer', '')
is_popup = 'popup=1' in referer or request.query_params.get('popup') == '1'
if is_popup:
request.session['oauth2_popup'] = True
request.session['oauth2_popup_mode'] = True
return RedirectResponse(url=auth_url, status_code=303)
except Exception as e:
logger.error(f"Google OAuth2 initiation failed: {e}")
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "error": "Google authentication service is temporarily unavailable"})
@router.get("/auth/oauth2/google/callback")
async def oauth2_google_callback(request: Request, code: str = Query(...), state: str = Query(...)):
"""Handle Google OAuth2 callback"""
if not (_config and _config.aisbf and _config.aisbf.oauth2 and
_config.aisbf.oauth2.google and _config.aisbf.oauth2.google.enabled):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
try:
from aisbf.auth.google import GoogleOAuth2
client_id = _config.aisbf.oauth2.google.client_id
client_secret = _config.aisbf.oauth2.google.client_secret
base_url = get_base_url(request)
redirect_uri = f"{base_url}/auth/oauth2/google/callback"
session_state = request.session.get('oauth2_google', {}).get('state')
if state != session_state:
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "config": _config, "error": "Invalid authentication state"})
oauth = GoogleOAuth2(client_id, client_secret, redirect_uri)
oauth._state = session_state
oauth._code_verifier = request.session.get('oauth2_google', {}).get('code_verifier')
tokens = await oauth.exchange_code_for_tokens(code, state)
if not tokens:
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "config": _config, "error": "Failed to authenticate with Google"})
user_info = await oauth.get_user_info(tokens.get('access_token'))
if not user_info or not user_info.get('email'):
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "config": _config, "error": "Could not retrieve your profile from Google"})
email = user_info.get('email')
email_verified = user_info.get('email_verified', False)
display_name = user_info.get('name', '')
db = DatabaseRegistry.get_config_database()
existing_user = db.get_user_by_email(email)
if existing_user:
request.session['logged_in'] = True
request.session['username'] = existing_user['username']
request.session['email'] = existing_user.get('email', '')
request.session['role'] = existing_user['role']
request.session['user_id'] = existing_user['id']
request.session['has_profile_pic'] = bool(existing_user.get('profile_pic'))
request.session['email_verified'] = True
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
with db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if db.db_type == 'sqlite' else '%s'
cursor.execute(f'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = {placeholder}', (existing_user['id'],))
conn.commit()
else:
if not email_verified:
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "config": _config, "error": "Google email must be verified to create an account"})
random_password = secrets.token_urlsafe(32)
password_hash = _db_hash_password(random_password)
google_username = db.generate_username_from_display_name(display_name, email)
final_username = db.find_unique_username(google_username)
user_id = db.create_user(final_username, password_hash, 'user', None, email, True, display_name)
request.session['logged_in'] = True
request.session['username'] = final_username
request.session['email'] = email
request.session['role'] = 'user'
request.session['user_id'] = user_id
request.session['email_verified'] = True
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
with db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if db.db_type == 'sqlite' else '%s'
cursor.execute(f'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = {placeholder}', (user_id,))
conn.commit()
is_popup = request.session.pop('oauth2_popup', False) or request.session.pop('oauth2_popup_mode', False)
request.session.pop('oauth2_google', None)
if is_popup:
return HTMLResponse(content=f'''
<!DOCTYPE html><html><head><title>Authentication Complete</title></head><body>
<script>
var msg = {{ type: 'oauth2_complete', redirect_url: '{url_for(request, "/dashboard")}' }};
try {{ var bc = new BroadcastChannel('oauth2_result'); bc.postMessage(msg); bc.close(); }} catch(e) {{}}
try {{ if (window.opener) window.opener.postMessage(msg, '*'); }} catch(e) {{}}
window.close();
</script></body></html>
''')
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
except Exception as e:
logger.error(f"Error during Google OAuth2 callback: {e}", exc_info=True)
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "config": _config, "error": "An error occurred during email verification"})
@router.get("/auth/oauth2/github")
async def oauth2_github_initiate(request: Request):
"""Initiate GitHub OAuth2 authentication flow"""
if not (_config and _config.aisbf and _config.aisbf.oauth2 and
_config.aisbf.oauth2.github and _config.aisbf.oauth2.github.enabled):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
try:
from aisbf.auth.github import GitHubOAuth2
client_id = _config.aisbf.oauth2.github.client_id
client_secret = _config.aisbf.oauth2.github.client_secret
base_url = get_base_url(request)
redirect_uri = f"{base_url}/auth/oauth2/github/callback"
oauth = GitHubOAuth2(client_id, client_secret, redirect_uri)
auth_url = oauth.get_authorization_url(_config.aisbf.oauth2.github.scopes)
request.session['oauth2_github'] = {'state': oauth._state}
referer = request.headers.get('Referer', '')
is_popup = 'popup=1' in referer or request.query_params.get('popup') == '1'
if is_popup:
request.session['oauth2_popup'] = True
request.session['oauth2_popup_mode'] = True
return RedirectResponse(url=auth_url, status_code=303)
except Exception as e:
logger.error(f"GitHub OAuth2 initiation failed: {e}")
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "error": "GitHub authentication service is temporarily unavailable"})
@router.get("/auth/oauth2/github/callback")
async def oauth2_github_callback(request: Request, code: str = Query(...), state: str = Query(...)):
"""Handle GitHub OAuth2 callback"""
if not (_config and _config.aisbf and _config.aisbf.oauth2 and
_config.aisbf.oauth2.github and _config.aisbf.oauth2.github.enabled):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
try:
from aisbf.auth.github import GitHubOAuth2
client_id = _config.aisbf.oauth2.github.client_id
client_secret = _config.aisbf.oauth2.github.client_secret
base_url = get_base_url(request)
redirect_uri = f"{base_url}/auth/oauth2/github/callback"
session_state = request.session.get('oauth2_github', {}).get('state')
if state != session_state:
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "config": _config, "error": "Invalid authentication state"})
oauth = GitHubOAuth2(client_id, client_secret, redirect_uri)
oauth._state = session_state
tokens = await oauth.exchange_code_for_tokens(code, state)
if not tokens:
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "config": _config, "error": "Failed to authenticate with GitHub"})
user_info = await oauth.get_user_info(tokens.get('access_token'))
if not user_info or not user_info.get('email'):
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "config": _config, "error": "Could not retrieve your profile from GitHub. Please ensure your email is public."})
email = user_info.get('email')
display_name = user_info.get('name', '') or user_info.get('login', '')
db = DatabaseRegistry.get_config_database()
existing_user = db.get_user_by_email(email)
if existing_user:
request.session['logged_in'] = True
request.session['username'] = existing_user['username']
request.session['email'] = existing_user.get('email', '')
request.session['role'] = existing_user['role']
request.session['user_id'] = existing_user['id']
request.session['has_profile_pic'] = bool(existing_user.get('profile_pic'))
request.session['email_verified'] = True
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
with db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if db.db_type == 'sqlite' else '%s'
cursor.execute(f'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = {placeholder}', (existing_user['id'],))
conn.commit()
else:
random_password = secrets.token_urlsafe(32)
password_hash = _db_hash_password(random_password)
github_username = db.generate_username_from_display_name(display_name, email)
final_username = db.find_unique_username(github_username)
user_id = db.create_user(final_username, password_hash, 'user', None, email, True, display_name)
request.session['logged_in'] = True
request.session['username'] = final_username
request.session['email'] = email
request.session['role'] = 'user'
request.session['user_id'] = user_id
request.session['email_verified'] = True
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
with db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if db.db_type == 'sqlite' else '%s'
cursor.execute(f'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = {placeholder}', (user_id,))
conn.commit()
is_popup = request.session.pop('oauth2_popup', False) or request.session.pop('oauth2_popup_mode', False)
request.session.pop('oauth2_github', None)
if is_popup:
return HTMLResponse(content=f'''
<!DOCTYPE html><html><head><title>Authentication Complete</title></head><body>
<script>
var msg = {{ type: 'oauth2_complete', redirect_url: '{url_for(request, "/dashboard")}' }};
try {{ var bc = new BroadcastChannel('oauth2_result'); bc.postMessage(msg); bc.close(); }} catch(e) {{}}
try {{ if (window.opener) window.opener.postMessage(msg, '*'); }} catch(e) {{}}
window.close();
</script></body></html>
''')
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
except Exception as e:
logger.error(f"GitHub OAuth2 callback failed: {e}", exc_info=True)
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "config": _config, "error": "Authentication failed. Please try again."})
def require_dashboard_auth(request: Request):
"""Check if user is logged in to dashboard"""
if not request.session.get('logged_in'):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
expires_at = request.session.get('expires_at')
if expires_at and int(time.time()) > expires_at:
request.session.clear()
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
if request.session.get('remember_me'):
request.session['expires_at'] = int(time.time()) + 30 * 24 * 60 * 60
elif expires_at:
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
if request.session.get('must_change_password'):
path = request.url.path
if not any(path.startswith(p) for p in _MUST_CHANGE_PASSWORD_WHITELIST):
return RedirectResponse(
url=url_for(request, "/dashboard/settings") + "?warning=default_password",
status_code=303
)
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"})
expires_at = request.session.get('expires_at')
if expires_at and int(time.time()) > expires_at:
request.session.clear()
return JSONResponse(status_code=401, content={"error": "Session expired"})
if request.session.get('remember_me'):
request.session['expires_at'] = int(time.time()) + 30 * 24 * 60 * 60
elif expires_at:
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
if request.session.get('must_change_password'):
path = request.url.path
if not any(path.startswith(p) for p in _MUST_CHANGE_PASSWORD_WHITELIST):
return JSONResponse(
status_code=403,
content={"error": "Default password must be changed before using the API",
"redirect": "/dashboard/settings?warning=default_password"}
)
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 (dashboard version - returns redirects)"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
if request.session.get('role') != 'admin':
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
return None
from fastapi import APIRouter, Request, Form, Query, HTTPException, UploadFile, File
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, Response, StreamingResponse
from typing import Optional
import json, logging, os, time, re, asyncio
from pathlib import Path
from datetime import datetime, timedelta
from aisbf.database import DatabaseRegistry
from aisbf.database import _hash_password as _db_hash_password
from aisbf import __version__
from aisbf.app.templates import url_for, get_base_url
from aisbf.app.startup import (_reload_global_config, _apply_condense_defaults_provider,
_apply_condense_defaults_rotation, _providers_json_path, _rotations_json_path,
_autoselect_json_path, get_aisbf_config_path)
from aisbf.routes.auth import require_dashboard_auth, require_api_auth, require_api_admin, require_admin
import httpx
router = APIRouter()
_config = None
_templates = None
logger = logging.getLogger(__name__)
def init(config, templates):
global _config, _templates
_config = config
_templates = templates
# User API token management routes
@router.get("/dashboard/user/tokens", response_class=HTMLResponse)
async def dashboard_user_tokens(request: Request):
"""User API token management page"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
if not user_id:
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
db = DatabaseRegistry.get_config_database()
# Get user API tokens
user_tokens = db.get_user_api_tokens(user_id)
# Convert datetime objects to strings for JSON serialization
for token in user_tokens:
if 'created_at' in token and token['created_at']:
token['created_at'] = token['created_at'].isoformat() if hasattr(token['created_at'], 'isoformat') else str(token['created_at'])
if 'last_used' in token and token['last_used']:
token['last_used'] = token['last_used'].isoformat() if hasattr(token['last_used'], 'isoformat') else str(token['last_used'])
return _templates.TemplateResponse(
request=request,
name="dashboard/user_tokens.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_tokens": user_tokens,
"user_id": user_id
}
)
@router.post("/dashboard/user/tokens")
async def dashboard_user_tokens_create(request: Request, description: str = Form(""), scope: str = Form("api")):
"""Create a new user API token"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
if not user_id:
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
if scope not in ('api', 'mcp', 'both'):
scope = 'api'
import secrets
db = DatabaseRegistry.get_config_database()
# Generate a secure token
token = secrets.token_urlsafe(32)
try:
token_id = db.create_user_api_token(user_id, token, description.strip() or None, scope)
return JSONResponse({
"message": "Token created successfully",
"token": token,
"token_id": token_id,
"scope": scope
})
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
@router.delete("/dashboard/user/tokens/{token_id}")
async def dashboard_user_tokens_delete(request: Request, token_id: int):
"""Delete a user API token"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
if not user_id:
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
db = DatabaseRegistry.get_config_database()
try:
db.delete_user_api_token(user_id, token_id)
return JSONResponse(content={"success": True})
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
@router.get("/dashboard/cache-settings", response_class=HTMLResponse)
async def dashboard_user_cache_settings(request: Request):
"""User prompt cache settings page"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
if not user_id:
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
db = DatabaseRegistry.get_config_database()
# Get all cache settings for user
cache_settings = db.get_all_user_cache_settings(user_id)
# Convert datetime objects to strings
for setting in cache_settings:
if 'created_at' in setting and setting['created_at']:
setting['created_at'] = setting['created_at'].isoformat() if hasattr(setting['created_at'], 'isoformat') else str(setting['created_at'])
if 'updated_at' in setting and setting['updated_at']:
setting['updated_at'] = setting['updated_at'].isoformat() if hasattr(setting['updated_at'], 'isoformat') else str(setting['updated_at'])
# Get user's providers for dropdown
user_providers = db.get_user_providers(user_id)
return _templates.TemplateResponse(
request=request,
name="dashboard/cache_settings.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"cache_settings": cache_settings,
"user_providers": user_providers,
"user_id": user_id
}
)
@router.get("/dashboard/api/cache-settings")
async def dashboard_api_get_cache_settings(request: Request):
"""Get logged-in user's cache settings"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
db = DatabaseRegistry.get_config_database()
provider_id = request.query_params.get('provider_id')
model_name = request.query_params.get('model_name')
if provider_id or model_name:
# Get specific setting
setting = db.get_user_cache_settings(user_id, provider_id, model_name)
# Convert datetime to string if present
if setting and 'updated_at' in setting and setting['updated_at']:
setting['updated_at'] = setting['updated_at'].isoformat() if hasattr(setting['updated_at'], 'isoformat') else str(setting['updated_at'])
return JSONResponse(setting)
else:
# Get all settings
settings = db.get_all_user_cache_settings(user_id)
# Convert datetime objects to strings
for setting in settings:
if 'updated_at' in setting and setting['updated_at']:
setting['updated_at'] = setting['updated_at'].isoformat() if hasattr(setting['updated_at'], 'isoformat') else str(setting['updated_at'])
if 'created_at' in setting and setting['created_at']:
setting['created_at'] = setting['created_at'].isoformat() if hasattr(setting['created_at'], 'isoformat') else str(setting['created_at'])
return JSONResponse({"settings": settings})
@router.post("/dashboard/api/cache-settings")
async def dashboard_api_set_cache_setting(request: Request):
"""Set logged-in user's cache setting"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
try:
body = await request.json()
provider_id = body.get('provider_id')
model_name = body.get('model_name')
cache_enabled = body.get('cache_enabled', True)
db = DatabaseRegistry.get_config_database()
success = db.set_user_cache_setting(user_id, cache_enabled, provider_id, model_name)
if success:
return JSONResponse({"success": True, "message": "Cache setting updated"})
else:
return JSONResponse(status_code=500, content={"error": "Failed to update setting"})
except Exception as e:
logger.error(f"Error setting cache setting: {e}")
return JSONResponse(status_code=500, content={"error": str(e)})
@router.delete("/dashboard/api/cache-settings")
async def dashboard_api_delete_cache_setting(request: Request):
"""Delete logged-in user's cache setting"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
try:
provider_id = request.query_params.get('provider_id')
model_name = request.query_params.get('model_name')
db = DatabaseRegistry.get_config_database()
success = db.delete_user_cache_setting(user_id, provider_id, model_name)
if success:
return JSONResponse({"success": True, "message": "Cache setting deleted"})
else:
return JSONResponse(status_code=500, content={"error": "Failed to delete setting"})
except Exception as e:
logger.error(f"Error deleting cache setting: {e}")
return JSONResponse(status_code=500, content={"error": str(e)})
@router.get("/dashboard/response-cache/stats")
async def dashboard_response_cache_stats(request: Request):
"""Get response cache statistics"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
is_admin = request.session.get('role') == 'admin'
current_user_id = request.session.get('user_id')
from aisbf.cache import get_response_cache
try:
cache = get_response_cache()
if is_admin:
# Admin sees global stats
stats = cache.get_stats()
else:
# Regular users see their own personal cache impact
stats = cache.get_user_stats(current_user_id)
return JSONResponse(stats)
except Exception as e:
logger.error(f"Error getting response cache stats: {e}")
return JSONResponse({
'enabled': False,
'hits': 0,
'misses': 0,
'hit_rate': 0.0,
'size': 0,
'evictions': 0,
'backend': 'unknown',
'error': str(e)
})
@router.get("/dashboard/admin/tiers")
async def dashboard_admin_tiers(request: Request):
"""Admin account tiers management page"""
auth_check = require_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
tiers = db.get_all_tiers()
return _templates.TemplateResponse(
request=request,
name="dashboard/admin_tiers.html",
context={
"request": request,
"session": request.session,
"tiers": tiers
}
)
# API endpoints for tiers CRUD operations
@router.get("/api/admin/tiers")
async def api_list_tiers(request: Request):
"""List all tiers - API endpoint"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
tiers = db.get_all_tiers()
return JSONResponse(tiers)
@router.get("/api/admin/tiers/{tier_id}")
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
db = DatabaseRegistry.get_config_database()
tier = db.get_tier_by_id(tier_id)
if not tier:
return JSONResponse({"error": "Tier not found"}, status_code=404)
return JSONResponse(tier)
@router.post("/api/admin/tiers")
async def api_create_tier(request: Request):
"""Create a new tier - API endpoint"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
try:
body = await request.json()
tier_id = db.create_tier(
name=body.get('name'),
description=body.get('description', ''),
price_monthly=body.get('price_monthly', 0.0),
price_yearly=body.get('price_yearly', 0.0),
max_requests_per_day=body.get('max_requests_per_day', -1),
max_requests_per_month=body.get('max_requests_per_month', -1),
max_providers=body.get('max_providers', -1),
max_rotations=body.get('max_rotations', -1),
max_autoselections=body.get('max_autoselections', -1),
max_rotation_models=body.get('max_rotation_models', -1),
max_autoselection_models=body.get('max_autoselection_models', -1),
is_active=body.get('is_active', True),
is_visible=body.get('is_visible', True)
)
return JSONResponse({"success": True, "tier_id": tier_id})
except Exception as e:
logger.error(f"Error creating tier: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@router.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_api_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
try:
body = await request.json()
# Build update kwargs
update_kwargs = {}
if 'name' in body:
update_kwargs['name'] = body['name']
if 'description' in body:
update_kwargs['description'] = body['description']
if 'price_monthly' in body:
update_kwargs['price_monthly'] = body['price_monthly']
if 'price_yearly' in body:
update_kwargs['price_yearly'] = body['price_yearly']
if 'max_requests_per_day' in body:
update_kwargs['max_requests_per_day'] = body['max_requests_per_day']
if 'max_requests_per_month' in body:
update_kwargs['max_requests_per_month'] = body['max_requests_per_month']
if 'max_providers' in body:
update_kwargs['max_providers'] = body['max_providers']
if 'max_rotations' in body:
update_kwargs['max_rotations'] = body['max_rotations']
if 'max_autoselections' in body:
update_kwargs['max_autoselections'] = body['max_autoselections']
if 'max_rotation_models' in body:
update_kwargs['max_rotation_models'] = body['max_rotation_models']
if 'max_autoselection_models' in body:
update_kwargs['max_autoselection_models'] = body['max_autoselection_models']
if 'is_active' in body:
update_kwargs['is_active'] = body['is_active']
if 'is_visible' in body:
update_kwargs['is_visible'] = body['is_visible']
success = db.update_tier(tier_id, **update_kwargs)
if not success:
return JSONResponse({"error": "Tier not found or no changes"}, status_code=404)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"Error updating tier: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@router.delete("/api/admin/tiers/{tier_id}")
async def api_delete_tier(request: Request, tier_id: int):
"""Delete a tier - API endpoint"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
try:
success = db.delete_tier(tier_id)
if not success:
return JSONResponse({"error": "Cannot delete default tier or tier not found"}, status_code=400)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"Error deleting tier: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
# Tier form pages
@router.get("/dashboard/admin/tiers/create")
async def dashboard_admin_tier_create(request: Request):
"""Create tier page"""
auth_check = require_admin(request)
if auth_check:
return auth_check
return _templates.TemplateResponse(
request=request,
name="dashboard/admin_tier_form.html",
context={
"request": request,
"session": request.session,
"tier": None
}
)
@router.get("/dashboard/admin/tiers/edit/{tier_id}")
async def dashboard_admin_tier_edit(request: Request, tier_id: int):
"""Edit tier page"""
auth_check = require_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
tier = db.get_tier_by_id(tier_id)
if not tier:
return RedirectResponse(url=url_for(request, "/dashboard/admin/tiers"), status_code=303)
return _templates.TemplateResponse(
request=request,
name="dashboard/admin_tier_form.html",
context={
"request": request,
"session": request.session,
"tier": tier
}
)
@router.post("/dashboard/admin/tiers/save")
async def dashboard_admin_tier_save(request: Request):
"""Save tier (create or update)"""
auth_check = require_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
try:
form = await request.form()
tier_id = form.get('tier_id')
tier_data = {
'name': form.get('name'),
'description': form.get('description', ''),
'price_monthly': float(form.get('price_monthly', 0)),
'price_yearly': float(form.get('price_yearly', 0)),
'max_requests_per_day': int(form.get('max_requests_per_day', -1)),
'max_requests_per_month': int(form.get('max_requests_per_month', -1)),
'max_providers': int(form.get('max_providers', -1)),
'max_rotations': int(form.get('max_rotations', -1)),
'max_autoselections': int(form.get('max_autoselections', -1)),
'max_rotation_models': int(form.get('max_rotation_models', -1)),
'max_autoselection_models': int(form.get('max_autoselection_models', -1)),
'is_active': form.get('is_active') == '1',
'is_visible': form.get('is_visible') == '1'
}
if tier_id:
# Update existing tier
db.update_tier(int(tier_id), **tier_data)
else:
# Create new tier
db.create_tier(**tier_data)
return RedirectResponse(url=url_for(request, "/dashboard/admin/tiers"), status_code=303)
except Exception as e:
logger.error(f"Error saving tier: {e}")
return RedirectResponse(url=url_for(request, "/dashboard/admin/tiers"), status_code=303)
# Currency settings endpoints
@router.get("/api/admin/settings/currency")
async def api_get_currency_settings(request: Request):
"""Get currency settings - API endpoint"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
# Get currency settings from database
settings = db.get_currency_settings()
return JSONResponse(settings)
@router.post("/api/admin/settings/currency")
async def api_save_currency_settings(request: Request):
"""Save currency settings - API endpoint"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
try:
body = await request.json()
db = DatabaseRegistry.get_config_database()
# Save currency settings to database
db.save_currency_settings(body)
return JSONResponse({"success": True, "message": "Currency settings saved"})
except Exception as e:
logger.error(f"Error saving currency settings: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
# Payment gateway settings endpoints
@router.get("/api/admin/settings/payment-gateways")
async def api_get_payment_gateways(request: Request):
"""Get payment gateway settings - API endpoint"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
# Get payment gateway settings from database
gateways = db.get_payment_gateway_settings()
return JSONResponse(gateways)
@router.post("/api/admin/settings/payment-gateways")
async def api_save_payment_gateways(request: Request):
"""Save payment gateway settings - API endpoint"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
try:
body = await request.json()
db = DatabaseRegistry.get_config_database()
# Save payment gateway settings to database
db.save_payment_gateway_settings(body)
return JSONResponse({"success": True, "message": "Payment gateway settings saved"})
except Exception as e:
logger.error(f"Error saving payment gateway settings: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@router.get("/api/admin/settings/encryption-key")
async def api_get_encryption_key_status(request: Request):
"""Get encryption key status - API endpoint"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
try:
db = DatabaseRegistry.get_config_database()
encryption_key = db.get_encryption_key()
# Check if key is set in database or environment
env_key = os.getenv('ENCRYPTION_KEY')
if encryption_key:
source = 'database'
is_set = True
elif env_key:
source = 'environment'
is_set = True
else:
source = 'temporary'
is_set = False
return JSONResponse({
"is_set": is_set,
"source": source
})
except Exception as e:
logger.error(f"Error getting encryption key status: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@router.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_api_admin(request)
if auth_check:
return auth_check
try:
db = DatabaseRegistry.get_config_database()
# Get enabled price sources
with db._get_connection() as conn:
cursor = conn.cursor()
try:
cursor.execute("""
SELECT name, is_enabled
FROM crypto_price_sources
""")
sources = {row[0].lower(): bool(row[1]) for row in cursor.fetchall()}
except Exception:
# Default if table doesn't exist yet
sources = {'coinbase': True, 'binance': True, 'kraken': True}
# Get currency settings
currency_settings = db.get_currency_settings()
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}"
# Coinbase
if sources.get('coinbase', False):
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(f'https://api.coinbase.com/v2/prices/{crypto_symbol}-{currency_code}/spot')
if response.status_code == 200:
data = response.json()
price = float(data['data']['amount'])
prices['coinbase'] = price
enabled_prices.append(price)
supported_pairs_cache[f"coinbase:{cache_key}"] = True
except Exception as 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
# Binance
if sources.get('binance', False):
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# 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:
# 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:
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
# Kraken
if sources.get('kraken', False):
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# 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 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:
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 only if we have valid prices
if enabled_prices:
prices['average'] = sum(enabled_prices) / len(enabled_prices)
else:
prices['average'] = None
result[crypto_name] = prices
return JSONResponse(result)
except Exception as e:
logger.error(f"Error getting crypto prices: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@router.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_api_admin(request)
if auth_check:
return auth_check
try:
# Call the new endpoint and extract BTC data
full_response = await api_get_crypto_prices(request)
if isinstance(full_response, JSONResponse):
import json
data = json.loads(full_response.body.decode())
if 'btc' in data:
return JSONResponse(data['btc'])
return full_response
except Exception as e:
logger.error(f"Error getting BTC prices: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@router.post("/api/admin/settings/encryption-key")
async def api_save_encryption_key(request: Request):
"""Save encryption key - API endpoint"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
try:
body = await request.json()
encryption_key = body.get('encryption_key', '').strip()
if not encryption_key:
return JSONResponse({"success": False, "error": "Encryption key is required"}, status_code=400)
if len(encryption_key) != 44:
return JSONResponse({"success": False, "error": "Encryption key must be 44 characters (base64 encoded)"}, status_code=400)
db = DatabaseRegistry.get_config_database()
success = db.save_encryption_key(encryption_key)
if success:
logger.info("Encryption key saved to database by admin")
return JSONResponse({"success": True, "message": "Encryption key saved successfully. Restart server to apply."})
else:
return JSONResponse({"success": False, "error": "Failed to save encryption key"}, status_code=500)
except Exception as e:
logger.error(f"Error saving encryption key: {e}")
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@router.post("/api/admin/settings/crypto-seeds-reset")
async def api_reset_crypto_seeds(request: Request):
"""Delete all crypto master seeds so they are regenerated on next restart"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
try:
db = DatabaseRegistry.get_config_database()
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM crypto_master_keys")
deleted = cursor.rowcount
conn.commit()
logger.warning(f"Admin reset crypto master seeds: {deleted} rows deleted")
return JSONResponse({"success": True, "deleted": deleted})
except Exception as e:
logger.error(f"Error resetting crypto seeds: {e}")
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
# Admin configuration API endpoints
@router.get("/api/admin/config/price-sources")
async def get_price_sources(request: Request):
"""Get crypto price source configuration"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT crypto_type, price_source, api_key, update_interval_seconds, is_enabled
FROM crypto_price_sources
""")
rows = cursor.fetchall()
sources = [
{
'crypto_type': row[0],
'price_source': row[1],
'api_key': row[2],
'update_interval': row[3],
'enabled': bool(row[4])
}
for row in rows
]
return JSONResponse({'price_sources': sources})
@router.put("/api/admin/payment-system/config/price-sources")
async def update_payment_price_sources(request: Request):
"""Update crypto price source configuration"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
try:
body = await request.json()
db = DatabaseRegistry.get_config_database()
with db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if db.db_type == 'sqlite' else '%s'
for source in body.get('price_sources', []):
cursor.execute(f"""
UPDATE crypto_price_sources
SET price_source = {placeholder},
api_key = {placeholder},
update_interval_seconds = {placeholder},
is_enabled = {placeholder}
WHERE crypto_type = {placeholder}
""", (
source['price_source'],
source.get('api_key'),
source['update_interval'],
source['enabled'],
source['crypto_type']
))
conn.commit()
return JSONResponse({'success': True, 'message': 'Price sources updated'})
except Exception as e:
logger.error(f"Error updating price sources: {e}")
return JSONResponse({'error': str(e)}, status_code=500)
@router.post("/api/admin/config/price-sources")
async def update_price_sources(request: Request):
"""Update crypto price source configuration (legacy endpoint)"""
return await update_payment_price_sources(request)
@router.get("/api/admin/config/consolidation")
async def get_consolidation_config(request: Request):
"""Get wallet consolidation configuration"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT crypto_type, threshold_amount, admin_address, is_enabled
FROM crypto_consolidation_settings
""")
rows = cursor.fetchall()
settings = [
{
'crypto_type': row[0],
'threshold': float(row[1]),
'admin_address': row[2],
'enabled': bool(row[3])
}
for row in rows
]
return JSONResponse({'consolidation_settings': settings})
@router.put("/api/admin/payment-system/config/consolidation")
async def update_payment_consolidation_config(request: Request):
"""Update wallet consolidation configuration"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
try:
body = await request.json()
db = DatabaseRegistry.get_config_database()
logger.info(f"Received consolidation config update: {body}")
with db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if db.db_type == 'sqlite' else '%s'
# Handle both old format (consolidation_settings array) and new format (btc/eth/usdt/usdc keys)
if 'consolidation_settings' in body:
# Old format
for setting in body['consolidation_settings']:
cursor.execute(f"""
UPDATE crypto_consolidation_settings
SET threshold_amount = {placeholder},
admin_address = {placeholder},
is_enabled = {placeholder}
WHERE crypto_type = {placeholder}
""", (
setting['threshold'],
setting.get('admin_address', ''),
setting.get('enabled', True),
setting['crypto_type']
))
logger.info(f"Updated {setting['crypto_type']} threshold to {setting['threshold']}")
else:
# New format - simple key-value pairs
crypto_map = {
'btc': 'BTC',
'eth': 'ETH',
'usdt': 'USDT',
'usdc': 'USDC'
}
updated_count = 0
for key, crypto_type in crypto_map.items():
if key in body:
threshold = float(body[key])
# Use UPSERT to handle missing records
if db.db_type == 'sqlite':
cursor.execute(f"""
INSERT INTO crypto_consolidation_settings (crypto_type, threshold_amount, admin_address, is_enabled)
VALUES (?, ?, '', 0)
ON CONFLICT(crypto_type) DO UPDATE SET threshold_amount = ?
""", (crypto_type, threshold, threshold))
else: # MySQL
cursor.execute(f"""
INSERT INTO crypto_consolidation_settings (crypto_type, threshold_amount, admin_address, is_enabled)
VALUES (%s, %s, '', 0)
ON DUPLICATE KEY UPDATE threshold_amount = %s
""", (crypto_type, threshold, threshold))
rows_affected = cursor.rowcount
logger.info(f"Upserted {crypto_type} threshold to {threshold}, rows affected: {rows_affected}")
updated_count += rows_affected
logger.info(f"Total rows affected: {updated_count}")
conn.commit()
logger.info("Consolidation settings committed to database")
return JSONResponse({'success': True, 'message': 'Consolidation settings updated'})
except Exception as e:
logger.error(f"Error updating consolidation settings: {e}", exc_info=True)
return JSONResponse({'success': False, 'error': str(e)}, status_code=500)
@router.post("/api/admin/config/consolidation")
async def update_consolidation_config(request: Request):
"""Update wallet consolidation configuration (legacy endpoint)"""
return await update_payment_consolidation_config(request)
@router.get("/api/admin/config/email")
async def get_email_config(request: Request):
"""Get email notification configuration"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
# Get SMTP config
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT smtp_host, smtp_port, smtp_username, from_email, from_name, use_tls
FROM email_config
LIMIT 1
""")
smtp_row = cursor.fetchone()
# Get notification settings
cursor.execute("""
SELECT notification_type, is_enabled, subject_template
FROM email_notification_settings
""")
notif_rows = cursor.fetchall()
smtp_config = None
if smtp_row:
smtp_config = {
'smtp_host': smtp_row[0],
'smtp_port': smtp_row[1],
'smtp_username': smtp_row[2],
'from_email': smtp_row[3],
'from_name': smtp_row[4],
'use_tls': bool(smtp_row[5])
}
notifications = [
{
'type': row[0],
'enabled': bool(row[1]),
'subject': row[2]
}
for row in notif_rows
]
return JSONResponse({
'smtp_config': smtp_config,
'notifications': notifications
})
@router.put("/api/admin/payment-system/config/email")
async def update_payment_email_config(request: Request):
"""Update email notification configuration"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
try:
body = await request.json()
db = DatabaseRegistry.get_config_database()
with db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if db.db_type == 'sqlite' else '%s'
# Update SMTP config
if 'smtp_config' in body:
smtp = body['smtp_config']
# Check if _config exists
cursor.execute("SELECT id FROM email_config LIMIT 1")
exists = cursor.fetchone()
if exists:
cursor.execute(f"""
UPDATE email_config
SET smtp_host = {placeholder},
smtp_port = {placeholder},
smtp_username = {placeholder},
smtp_password = {placeholder},
from_email = {placeholder},
from_name = {placeholder},
use_tls = {placeholder}
""", (
smtp['smtp_host'],
smtp['smtp_port'],
smtp.get('smtp_username'),
smtp.get('smtp_password'),
smtp['from_email'],
smtp.get('from_name'),
smtp.get('use_tls', True)
))
else:
cursor.execute(f"""
INSERT INTO email_config
(smtp_host, smtp_port, smtp_username, smtp_password, from_email, from_name, use_tls)
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder})
""", (
smtp['smtp_host'],
smtp['smtp_port'],
smtp.get('smtp_username'),
smtp.get('smtp_password'),
smtp['from_email'],
smtp.get('from_name'),
smtp.get('use_tls', True)
))
# Update notification settings
if 'notifications' in body:
for notif in body['notifications']:
cursor.execute(f"""
UPDATE email_notification_settings
SET is_enabled = {placeholder},
subject_template = {placeholder}
WHERE notification_type = {placeholder}
""", (
notif['enabled'],
notif['subject'],
notif['type']
))
conn.commit()
return JSONResponse({'success': True, 'message': 'Email configuration updated'})
except Exception as e:
logger.error(f"Error updating email configuration: {e}")
return JSONResponse({'error': str(e)}, status_code=500)
@router.post("/api/admin/config/email")
async def update_email_config(request: Request):
"""Update email notification configuration (legacy endpoint)"""
return await update_payment_email_config(request)
@router.put("/api/admin/payment-system/config/blockchain")
async def update_payment_blockchain_config(request: Request):
"""Update blockchain monitoring configuration"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
try:
body = await request.json()
db = DatabaseRegistry.get_config_database()
with db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if db.db_type == 'sqlite' else '%s'
for config in body.get('blockchain_config', []):
cursor.execute(f"""
UPDATE blockchain_monitoring_config
SET rpc_url = {placeholder},
confirmations_required = {placeholder},
scan_interval_seconds = {placeholder},
is_enabled = {placeholder}
WHERE crypto_type = {placeholder}
""", (
config['rpc_url'],
config['confirmations'],
config['scan_interval'],
config['enabled'],
config['crypto_type']
))
conn.commit()
return JSONResponse({'success': True, 'message': 'Blockchain monitoring configuration updated'})
except Exception as e:
logger.error(f"Error updating blockchain monitoring configuration: {e}")
return JSONResponse({'error': str(e)}, status_code=500)
@router.get("/api/admin/scheduler/status")
async def get_scheduler_status(request: Request):
"""Get payment scheduler status"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
if not payment_service:
return JSONResponse({'error': 'Payment service not initialized'}, status_code=503)
try:
from aisbf.payments.scheduler import PaymentScheduler
# Get scheduler from payment service if available
if hasattr(payment_service, 'scheduler'):
status = payment_service.scheduler.get_job_status()
return JSONResponse(status)
else:
return JSONResponse({'error': 'Scheduler not available'}, status_code=503)
except Exception as e:
logger.error(f"Error getting scheduler status: {e}")
return JSONResponse({'error': str(e)}, status_code=500)
@router.post("/api/admin/scheduler/run-job")
async def run_scheduler_job(request: Request):
"""Manually trigger a scheduler job"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
if not payment_service:
return JSONResponse({'error': 'Payment service not initialized'}, status_code=503)
try:
body = await request.json()
job_name = body.get('job_name')
if not job_name:
return JSONResponse({'error': 'job_name required'}, status_code=400)
if hasattr(payment_service, 'scheduler'):
await payment_service.scheduler.run_job_now(job_name)
return JSONResponse({'success': True, 'message': f'Job {job_name} triggered'})
else:
return JSONResponse({'error': 'Scheduler not available'}, status_code=503)
except ValueError as e:
return JSONResponse({'error': str(e)}, status_code=400)
except Exception as e:
logger.error(f"Error running scheduler job: {e}")
return JSONResponse({'error': str(e)}, status_code=500)
@router.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_api_admin(request)
if auth_check:
return auth_check
try:
db = DatabaseRegistry.get_config_database()
with db._get_connection() as conn:
cursor = conn.cursor()
# Check master keys status
cursor.execute("SELECT COUNT(*) FROM crypto_master_keys")
master_keys_count = cursor.fetchone()[0]
# Get total crypto balances from user_crypto_wallets
try:
cursor.execute("""
SELECT crypto_type, SUM(balance_fiat) as total
FROM user_crypto_wallets
GROUP BY crypto_type
""")
balances = {row[0]: float(row[1]) for row in cursor.fetchall()}
total_balance_usd = sum(balances.values())
except Exception:
balances = {}
total_balance_usd = 0.0
# Get pending payments count from payment_transactions
try:
cursor.execute("""
SELECT COUNT(*) FROM payment_transactions
WHERE status = 'pending'
""")
pending_count = cursor.fetchone()[0]
except Exception:
pending_count = 0
# Get failed payments count from payment_transactions
try:
cursor.execute("""
SELECT COUNT(*) FROM payment_transactions
WHERE status = 'failed'
""")
failed_count = cursor.fetchone()[0]
except Exception:
failed_count = 0
return JSONResponse({
'master_keys_initialized': master_keys_count > 0,
'master_keys_count': master_keys_count,
'total_balance_usd': total_balance_usd,
'pending_payments': pending_count,
'failed_payments': failed_count
})
except Exception as e:
logger.error(f"Error getting payment system status: {e}")
return JSONResponse({'error': str(e)}, status_code=500)
@router.get("/api/admin/payment-system/config")
async def get_payment_system_config(request: Request):
"""Get all payment system configuration"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
try:
db = DatabaseRegistry.get_config_database()
with db._get_connection() as conn:
cursor = conn.cursor()
# Get price sources
try:
cursor.execute("""
SELECT name, api_type, endpoint_url, api_key, is_enabled
FROM crypto_price_sources
""")
price_sources = {
row[0].lower(): bool(row[4])
for row in cursor.fetchall()
}
except Exception:
price_sources = {
'coinbase': True,
'binance': True,
'kraken': True
}
# Get blockchain monitoring config (default values)
blockchain_config = {
'mode': 'api',
'polling_interval': 60
}
# Get email notification config (default values)
email_config = {
'payment_success': True,
'payment_failed': True,
'subscription_upgraded': True,
'subscription_downgraded': True,
'subscription_cancelled': True,
'payment_retry': True
}
# Get consolidation settings
try:
cursor.execute("""
SELECT crypto_type, threshold_amount
FROM crypto_consolidation_settings
""")
consolidation = {
row[0].lower(): float(row[1])
for row in cursor.fetchall()
}
except Exception:
consolidation = {
'btc': 0.01,
'eth': 0.1,
'usdt': 100,
'usdc': 100
}
return JSONResponse({
'price_sources': price_sources,
'blockchain': blockchain_config,
'email_notifications': email_config,
'consolidation': consolidation
})
except Exception as e:
logger.error(f"Error getting payment system config: {e}")
return JSONResponse({'error': str(e)}, status_code=500)
@router.get("/dashboard/admin/payment-settings")
async def dashboard_admin_payment_settings(request: Request):
"""Admin payment system settings page"""
auth_check = require_admin(request)
if auth_check:
return auth_check
return _templates.TemplateResponse(
request=request,
name="dashboard/admin_payment_settings.html",
context={
"request": request,
"session": request.session,
"currency_symbol": DatabaseRegistry.get_config_database().get_currency_settings().get('currency_symbol', '$')
}
)
@router.get("/dashboard/pricing")
async def dashboard_pricing(request: Request):
"""Pricing plans page for users"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
tiers = db.get_visible_tiers()
current_tier = db.get_user_tier(user_id)
# Mark the most expensive non-free tier as recommended if none marked
paid_tiers = [t for t in tiers if not t.get('is_default')]
if paid_tiers:
most_expensive = max(paid_tiers, key=lambda t: t['price_monthly'])
for t in tiers:
t['is_recommended'] = (not t.get('is_default') and t['id'] == most_expensive['id'])
# Get enabled payment gateways
enabled_gateways = []
gateways = db.get_payment_gateway_settings()
for gateway, settings in gateways.items():
if settings.get('enabled', False):
enabled_gateways.append(gateway)
# Get currency settings
currency_settings = db.get_currency_settings()
currency_symbol = currency_settings.get('currency_symbol', '$')
# Get wallet balance for display
wallet_balance = None
has_stripe_card = False
if user_id:
try:
from aisbf.payments.wallet.manager import WalletManager
wallet_manager = WalletManager(db)
wallet = await wallet_manager.get_wallet(user_id)
wallet_balance = float(wallet.get('balance', 0))
except Exception:
wallet_balance = 0.0
payment_methods = db.get_user_payment_methods(user_id)
has_stripe_card = any(m.get('type') == 'stripe' and m.get('is_active') for m in payment_methods)
return _templates.TemplateResponse(
request=request,
name="dashboard/pricing.html",
context={
"request": request,
"session": request.session,
"tiers": tiers,
"current_tier": current_tier,
"enabled_gateways": enabled_gateways,
"currency_symbol": currency_symbol,
"wallet_balance": wallet_balance,
"has_stripe_card": has_stripe_card,
"success": request.query_params.get("success"),
"error": request.query_params.get("error"),
}
)
@router.post("/dashboard/subscribe/free")
async def dashboard_subscribe_free(request: Request):
"""Downgrade to the free tier"""
from fastapi.responses import JSONResponse
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
user_id = request.session.get('user_id')
db = DatabaseRegistry.get_config_database()
free_tiers = [t for t in db.get_visible_tiers() if t.get('is_default')]
if not free_tiers:
return JSONResponse({"error": "No free tier configured"}, status_code=404)
free_tier = free_tiers[0]
ph = db.placeholder
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
f"UPDATE user_subscriptions SET status = 'cancelled' WHERE user_id = {ph} AND status = 'active'",
(user_id,)
)
conn.commit()
db.set_user_tier(user_id, free_tier['id'])
return JSONResponse({"success": True, "message": "Downgraded to free plan. Changes are effective immediately."})
def _create_subscription_record(db, user_id: int, tier_id: int):
"""Cancel existing active subscriptions and create a new one for the given tier."""
from datetime import datetime, timedelta
ph = db.placeholder
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
f"UPDATE user_subscriptions SET status = 'cancelled' WHERE user_id = {ph} AND status = 'active'",
(user_id,)
)
start_date = datetime.now()
end_date = start_date + timedelta(days=30)
cursor.execute(f"""
INSERT INTO user_subscriptions (user_id, tier_id, status, start_date, next_billing_date)
VALUES ({ph}, {ph}, 'active', {ph}, {ph})
""", (user_id, tier_id, start_date, end_date))
conn.commit()
@router.post("/dashboard/subscribe/{tier_id}")
async def dashboard_subscribe_tier(request: Request, tier_id: int):
"""Subscribe/upgrade to a paid tier using wallet or saved Stripe card"""
from fastapi.responses import JSONResponse
from decimal import Decimal
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
user_id = request.session.get('user_id')
db = DatabaseRegistry.get_config_database()
target_tier = db.get_tier_by_id(tier_id)
if not target_tier or not target_tier.get('is_active'):
return JSONResponse({"error": "Invalid or inactive plan"}, status_code=404)
current_tier = db.get_user_tier(user_id)
if current_tier and current_tier['id'] == tier_id:
return JSONResponse({"error": "You are already on this plan"}, status_code=400)
tier_price = float(target_tier['price_monthly'])
# Get wallet balance
from aisbf.payments.wallet.manager import WalletManager
wallet_manager = WalletManager(db)
try:
wallet = await wallet_manager.get_wallet(user_id)
wallet_balance = float(wallet.get('balance', 0))
except Exception:
wallet_balance = 0.0
if wallet_balance >= tier_price:
# Pay with wallet
try:
await wallet_manager.debit_wallet(user_id, Decimal(str(tier_price)), {
"description": f"Plan upgrade to {target_tier['name']}",
"payment_gateway": "wallet",
"gateway_transaction_id": None,
"payment_method_id": None,
})
except Exception as e:
return JSONResponse({"error": f"Failed to debit wallet: {str(e)}"}, status_code=400)
_create_subscription_record(db, user_id, tier_id)
db.set_user_tier(user_id, tier_id)
return JSONResponse({
"success": True,
"message": f"Upgraded to {target_tier['name']}. ${tier_price:.2f} deducted from your wallet."
})
# Wallet insufficient — check Stripe
payment_methods = db.get_user_payment_methods(user_id)
stripe_methods = [m for m in payment_methods if m.get('type') == 'stripe' and m.get('is_active')]
if not stripe_methods:
shortage = tier_price - wallet_balance
return JSONResponse({
"error": "insufficient_funds",
"wallet_balance": wallet_balance,
"required": tier_price,
"shortage": round(shortage, 2),
"message": (
f"Your wallet balance (${wallet_balance:.2f}) is insufficient. "
f"You need ${shortage:.2f} more. Top up your wallet or add a card."
),
}, status_code=402)
# Charge the default (or first) Stripe card for the exact plan amount
default_method = next((m for m in stripe_methods if m.get('is_default')), stripe_methods[0])
if payment_service is None:
return JSONResponse({"error": "Payment service unavailable"}, status_code=503)
result = await payment_service.stripe_handler.auto_charge(
user_id,
Decimal(str(tier_price)),
default_method['identifier'],
description=f"Subscription upgrade to {target_tier['name']}",
metadata={'user_id': str(user_id), 'tier_id': str(tier_id), 'tier_name': target_tier['name'], 'amount': str(tier_price)},
off_session=False
)
if not result.get('success'):
return JSONResponse({"error": result.get('error', 'Card charge failed')}, status_code=402)
_create_subscription_record(db, user_id, tier_id)
db.set_user_tier(user_id, tier_id)
return JSONResponse({
"success": True,
"message": f"Upgraded to {target_tier['name']}. ${tier_price:.2f} charged to your saved card."
})
@router.get("/dashboard/usage", response_class=HTMLResponse)
async def dashboard_usage(request: Request):
"""Usage and quota page for users"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
from datetime import datetime, timedelta
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
current_tier = db.get_user_tier(user_id) if user_id else None
all_tiers = db.get_visible_tiers()
# Quota limits from tier
max_requests_per_day = current_tier.get('max_requests_per_day', -1) if current_tier else -1
max_requests_per_month = current_tier.get('max_requests_per_month', -1) if current_tier else -1
max_providers = current_tier.get('max_providers', -1) if current_tier else -1
max_rotations = current_tier.get('max_rotations', -1) if current_tier else -1
max_autoselections = current_tier.get('max_autoselections', -1) if current_tier else -1
# Actual usage
requests_today = 0
requests_month = 0
tokens_24h = 0
providers_count = 0
rotations_count = 0
autoselects_count = 0
now = datetime.now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
# Exact reset timestamps as JS-compatible millisecond Unix timestamps
daily_reset_ts = int((today_start + timedelta(days=1)).timestamp() * 1000)
monthly_reset_ts = int((month_start + timedelta(days=32)).replace(
day=1, hour=0, minute=0, second=0, microsecond=0).timestamp() * 1000)
if user_id:
token_usage = db.get_user_token_usage(user_id)
day_ago = now - timedelta(days=1)
for row in token_usage:
ts = row['timestamp']
if isinstance(ts, str):
try:
ts = datetime.fromisoformat(ts)
except Exception:
continue
if ts >= today_start:
requests_today += 1
if ts >= month_start:
requests_month += 1
if ts >= day_ago:
tokens_24h += int(row.get('token_count', 0) or 0)
providers_count = len(db.get_user_providers(user_id))
rotations_count = len(db.get_user_rotations(user_id))
autoselects_count = len(db.get_user_autoselects(user_id))
upgrade_tiers = [
t for t in all_tiers
if not t.get('is_default') and (
current_tier is None or t['price_monthly'] > current_tier.get('price_monthly', 0)
)
]
currency_settings = db.get_currency_settings()
currency_symbol = currency_settings.get('currency_symbol', '$')
return _templates.TemplateResponse(
request=request,
name="dashboard/usage.html",
context={
"request": request,
"session": request.session,
"current_tier": current_tier,
"max_requests_per_day": max_requests_per_day,
"max_requests_per_month": max_requests_per_month,
"max_providers": max_providers,
"max_rotations": max_rotations,
"max_autoselections": max_autoselections,
"requests_today": requests_today,
"requests_month": requests_month,
"tokens_24h": tokens_24h,
"providers_count": providers_count,
"rotations_count": rotations_count,
"autoselects_count": autoselects_count,
"upgrade_tiers": upgrade_tiers,
"currency_symbol": currency_symbol,
"daily_reset_ts": daily_reset_ts,
"monthly_reset_ts": monthly_reset_ts,
}
)
@router.get("/dashboard/subscription")
async def dashboard_subscription(request: Request):
"""User subscription status and payment methods management page"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
# Get user subscription info
subscription = db.get_user_subscription(user_id)
current_tier = db.get_user_tier(user_id)
payment_methods = db.get_user_payment_methods(user_id)
# Get enabled payment gateways
enabled_gateways = []
gateways = db.get_payment_gateway_settings()
for gateway, settings in gateways.items():
if settings.get('enabled', False):
enabled_gateways.append(gateway)
# Get currency settings
currency_settings = db.get_currency_settings()
currency_symbol = currency_settings.get('currency_symbol', '$')
return _templates.TemplateResponse(
request=request,
name="dashboard/subscription.html",
context={
"request": request,
"session": request.session,
"subscription": subscription,
"current_tier": current_tier,
"payment_methods": payment_methods,
"enabled_gateways": enabled_gateways,
"currency_symbol": currency_symbol
}
)
@router.get("/dashboard/wallet", response_class=HTMLResponse)
async def dashboard_wallet(request: Request):
"""User wallet dashboard page"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
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)
all_gateways = db.get_payment_gateway_settings()
enabled_gateways = {k: v for k, v in all_gateways.items() if v.get('enabled', False)}
# Get user's saved Stripe credit cards for auto top-up
stripe_cards = [m for m in db.get_user_payment_methods(user_id)
if m.get('type') == 'stripe' or m.get('gateway') == 'stripe']
# Determine if there are upgrade plans available
current_tier = db.get_user_tier(user_id)
all_tiers = db.get_visible_tiers()
upgrade_tiers = [
t for t in all_tiers
if not t.get('is_default') and (
current_tier is None or t['price_monthly'] > current_tier.get('price_monthly', 0)
)
]
return _templates.TemplateResponse(
request=request,
name="dashboard/wallet.html",
context={
"request": request,
"wallet": wallet,
"enabled_gateways": enabled_gateways,
"currency_symbol": db.get_currency_settings().get('currency_symbol', '$'),
"stripe_cards": stripe_cards,
"upgrade_tiers": upgrade_tiers,
}
)
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(request=request, name="dashboard/error.html", context={
"request": request,
"error": "Failed to load wallet. Please try again later."
}, status_code=500)
@router.post("/dashboard/wallet/topup")
async def dashboard_wallet_topup(request: Request):
"""Session-authenticated wallet top-up — supports all admin-enabled gateways."""
from fastapi.responses import JSONResponse
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
user_id = request.session.get('user_id')
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid request body"}, status_code=400)
method = (body.get('payment_method') or '').lower()
amount = body.get('amount')
try:
amount = float(amount)
except (TypeError, ValueError):
return JSONResponse({"error": "Invalid amount"}, status_code=400)
if amount < 5 or amount > 500:
return JSONResponse({"error": "Amount must be between $5 and $500"}, status_code=400)
db = DatabaseRegistry.get_config_database()
gateways = db.get_payment_gateway_settings()
gw = gateways.get(method, {})
if not gw.get('enabled', False):
return JSONResponse({"error": f"Payment method '{method}' is not enabled"}, status_code=400)
# Crypto: generate per-user HD wallet address
crypto_methods = {'bitcoin': 'btc', 'ethereum': 'eth', 'usdt': 'usdt', 'usdc': 'usdc'}
if method in crypto_methods:
crypto_type = crypto_methods[method]
ps = getattr(request.app.state, 'payment_service', None)
if ps is None:
return JSONResponse({"error": "Payment service unavailable"}, status_code=503)
try:
address = await ps.wallet_manager.get_or_create_user_address(user_id, crypto_type)
except Exception as e:
import traceback as _tb
logger.error(f"Crypto address generation error: {e!r}\n{_tb.format_exc()}")
return JSONResponse({"error": "Could not generate deposit address"}, status_code=503)
return JSONResponse({
"type": "crypto",
"method": method,
"address": address,
"amount": amount,
"network": gw.get('network', ''),
"confirmations": gw.get('confirmations', 3),
})
# Stripe: create checkout session (hosted redirect flow)
if method == 'stripe':
try:
payment_service = getattr(request.app.state, 'payment_service', None)
if not payment_service or not hasattr(payment_service, 'stripe_handler'):
return JSONResponse({"error": "Stripe payment service unavailable"}, status_code=503)
from decimal import Decimal
base = get_base_url(request)
result = await payment_service.stripe_handler.create_topup_checkout_session(
user_id,
Decimal(str(amount)),
success_url=f"{base}/dashboard/wallet?topup=success",
cancel_url=f"{base}/dashboard/wallet?topup=cancelled",
)
if not result.get('success'):
return JSONResponse({"error": result.get('error', 'Stripe error')}, status_code=502)
return JSONResponse({"type": "stripe", "checkout_url": result['checkout_url']})
except Exception as e:
logger.error(f"Stripe top-up error: {e}")
return JSONResponse({"error": "Stripe checkout failed. Please try again."}, status_code=502)
# PayPal: create order
if method == 'paypal':
try:
payment_service = getattr(request.app.state, 'payment_service', None)
if not payment_service or not hasattr(payment_service, 'paypal_handler'):
return JSONResponse({"error": "PayPal payment service unavailable"}, status_code=503)
from decimal import Decimal
result = await payment_service.paypal_handler.create_topup_order(user_id, Decimal(str(amount)))
if not result.get('success'):
logger.error(f"PayPal top-up error: {result.get('error')}")
return JSONResponse({"error": "PayPal checkout failed. Please try again."}, status_code=502)
return JSONResponse({"type": "paypal", "approval_url": result['approval_url']})
except Exception as e:
logger.error(f"PayPal top-up error: {e}")
return JSONResponse({"error": "PayPal checkout failed. Please try again."}, status_code=502)
return JSONResponse({"error": f"Unsupported payment method: {method}"}, status_code=400)
@router.get("/dashboard/wallet/transactions")
async def dashboard_wallet_transactions(request: Request, limit: int = 50, offset: int = 0):
"""Session-authenticated wallet transaction history (used by the wallet dashboard page)."""
auth_check = require_dashboard_auth(request)
if auth_check:
from fastapi.responses import JSONResponse
return JSONResponse({"error": "Unauthorized"}, status_code=401)
user_id = request.session.get('user_id')
try:
from aisbf.payments.wallet.manager import WalletManager
db = DatabaseRegistry.get_config_database()
wallet_manager = WalletManager(db)
transactions = await wallet_manager.get_transactions(user_id, limit=limit, offset=offset)
return transactions
except Exception as e:
logger.error(f"Failed to load wallet transactions: {e}")
from fastapi.responses import JSONResponse
return JSONResponse({"error": "Failed to load transactions"}, status_code=500)
@router.put("/dashboard/wallet/auto-topup")
async def dashboard_wallet_auto_topup(request: Request):
"""Session-authenticated auto-topup configuration (used by the wallet dashboard page)."""
auth_check = require_dashboard_auth(request)
if auth_check:
from fastapi.responses import JSONResponse
return JSONResponse({"error": "Unauthorized"}, status_code=401)
user_id = request.session.get('user_id')
try:
body = await request.json()
from aisbf.payments.wallet.manager import WalletManager
db = DatabaseRegistry.get_config_database()
wallet_manager = WalletManager(db)
result = await wallet_manager.configure_auto_topup(user_id, body)
from fastapi.responses import JSONResponse
return JSONResponse(result)
except Exception as e:
logger.error(f"Failed to configure auto-topup: {e}")
from fastapi.responses import JSONResponse
return JSONResponse({"error": "Failed to save settings"}, status_code=500)
@router.get("/dashboard/billing")
async def dashboard_billing(request: Request):
"""User payment transaction history page"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
# Get user payment methods
payment_methods = db.get_user_payment_methods(user_id)
# Get payment transactions
transactions = db.get_user_payment_transactions(user_id)
# Get enabled payment gateways
enabled_gateways = []
gateways = db.get_payment_gateway_settings()
for gateway, settings in gateways.items():
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')
try:
from aisbf.payments.wallet.manager import WalletManager
wallet_manager = WalletManager(db)
wallet = await wallet_manager.get_wallet(user_id)
except Exception:
wallet = {'balance': '0.00', 'currency_code': currency_code, 'auto_topup_enabled': False}
# Get Stripe publishable key
stripe_publishable_key = ""
if 'stripe' in gateways and gateways['stripe'].get('enabled'):
stripe_publishable_key = gateways['stripe'].get('publishable_key', '')
return _templates.TemplateResponse(
request=request,
name="dashboard/billing.html",
context={
"request": request,
"session": request.session,
"payment_methods": payment_methods,
"transactions": transactions,
"enabled_gateways": enabled_gateways,
"wallet": wallet,
"currency_symbol": currency_settings.get('currency_symbol', '$'),
"stripe_publishable_key": stripe_publishable_key,
}
)
from fastapi import APIRouter, Request, Form, Query, HTTPException, UploadFile, File
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, Response, StreamingResponse
from typing import Optional
import json, logging, os, time, re
from pathlib import Path
from datetime import datetime, timedelta
from aisbf.database import DatabaseRegistry
from aisbf import __version__
from aisbf.app.templates import url_for, get_base_url
from aisbf.app.startup import (_reload_global_config, _apply_condense_defaults_provider,
_apply_condense_defaults_rotation, _providers_json_path, _rotations_json_path,
_autoselect_json_path)
from aisbf.routes.auth import require_dashboard_auth, require_api_auth, require_api_admin, require_admin
import httpx
try:
import markdown
except ImportError:
markdown = None
router = APIRouter()
_config = None
_templates = None
logger = logging.getLogger(__name__)
def init(config, templates):
global _config, _templates
_config = config
_templates = templates
@router.get("/dashboard/billing/add-method", response_class=HTMLResponse)
async def dashboard_add_payment_method(request: Request):
"""Add payment method page"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
enabled_gateways = []
gateways = db.get_payment_gateway_settings()
for gateway, settings in gateways.items():
if settings.get('enabled', False):
enabled_gateways.append(gateway)
stripe_publishable_key = ""
if 'stripe' in gateways and gateways['stripe'].get('enabled'):
stripe_publishable_key = gateways['stripe'].get('publishable_key', '')
return _templates.TemplateResponse(
request=request,
name="dashboard/add_payment_method.html",
context={
"request": request,
"session": request.session,
"enabled_gateways": enabled_gateways,
"stripe_publishable_key": stripe_publishable_key
}
)
@router.post("/dashboard/billing/add-method")
async def dashboard_add_payment_method_post(request: Request):
"""Handle crypto default setting"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
data = await request.json()
user_id = request.session.get('user_id')
payment_type = data.get('type')
if payment_type in ['bitcoin', 'eth', 'usdt', 'usdc']:
db = DatabaseRegistry.get_config_database()
# Set as default payment method
db.set_user_default_payment_method(user_id, payment_type)
return JSONResponse({"success": True, "message": f"{payment_type.upper()} set as default payment method"})
return JSONResponse({"success": False, "error": "Invalid payment type"}, status_code=400)
@router.post("/dashboard/billing/add-method/stripe")
async def dashboard_add_payment_method_stripe(request: Request):
"""Handle Stripe payment method addition"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
data = await request.json()
user_id = request.session.get('user_id')
payment_method_id = data.get('payment_method_id')
if not payment_method_id:
return JSONResponse({"success": False, "error": "Payment method ID required"}, status_code=400)
db = DatabaseRegistry.get_config_database()
try:
# Attach the PM to the Stripe customer so it can be charged later
if payment_service and payment_service.stripe_handler:
customer_id = await payment_service.stripe_handler._get_or_create_customer(user_id)
import stripe as _stripe
import asyncio as _asyncio
try:
await _asyncio.to_thread(
_stripe.PaymentMethod.attach,
payment_method_id,
customer=customer_id
)
await _asyncio.to_thread(
_stripe.Customer.modify,
customer_id,
invoice_settings={'default_payment_method': payment_method_id}
)
except _stripe.error.InvalidRequestError as e:
if 'already been attached' not in str(e):
raise
method_id = db.add_payment_method(user_id, 'stripe', payment_method_id, is_default=True, metadata={'stripe_payment_method_id': payment_method_id})
return JSONResponse({"success": True, "message": "Credit card added successfully"})
except Exception as e:
logger.error(f"Error adding Stripe payment method: {e}")
return JSONResponse({"success": False, "error": "Failed to add payment method"}, status_code=500)
@router.delete("/dashboard/billing/payment-methods/{method_id}")
async def dashboard_delete_payment_method(request: Request, method_id: int):
"""Delete a payment method"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
try:
# Delete the payment method
success = db.delete_payment_method(user_id, method_id)
if success:
logger.info(f"Payment method {method_id} deleted for user {user_id}")
return JSONResponse({"success": True, "message": "Payment method deleted successfully"})
else:
return JSONResponse({"success": False, "error": "Payment method not found or already deleted"}, status_code=404)
except Exception as e:
logger.error(f"Error deleting payment method: {e}")
return JSONResponse({"success": False, "error": "Failed to delete payment method"}, status_code=500)
@router.post("/dashboard/billing/payment-methods/{method_id}/set-default")
async def dashboard_set_default_payment_method(request: Request, method_id: int):
"""Set a payment method as default"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
try:
# Get the payment method to verify it belongs to the user
payment_methods = db.get_user_payment_methods(user_id)
method_exists = any(m['id'] == method_id for m in payment_methods)
if not method_exists:
return JSONResponse({"success": False, "error": "Payment method not found"}, status_code=404)
# Set as default by updating all methods for this user
with db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if db.db_type == 'sqlite' else '%s'
# Unset all defaults for this user
cursor.execute(f'''
UPDATE payment_methods SET is_default = 0
WHERE user_id = {placeholder}
''', (user_id,))
# Set the selected method as default
cursor.execute(f'''
UPDATE payment_methods SET is_default = 1
WHERE id = {placeholder} AND user_id = {placeholder}
''', (method_id, user_id))
conn.commit()
logger.info(f"Payment method {method_id} set as default for user {user_id}")
return JSONResponse({"success": True, "message": "Payment method set as default"})
except Exception as e:
logger.error(f"Error setting default payment method: {e}")
return JSONResponse({"success": False, "error": "Failed to set default payment method"}, status_code=500)
@router.get("/dashboard/billing/add-method/paypal/oauth")
async def dashboard_add_payment_method_paypal_oauth(request: Request):
"""Initiate PayPal Vault setup flow"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
# Get PayPal settings
gateways = db.get_payment_gateway_settings()
paypal_settings = gateways.get('paypal', {})
# Validate PayPal is enabled
if not paypal_settings.get('enabled'):
logger.warning(f"PayPal OAuth attempted but PayPal is not enabled (user_id={user_id})")
return RedirectResponse(
url="/dashboard/billing?error=PayPal is not enabled",
status_code=302
)
# Check if user already has PayPal as payment method
existing_methods = db.get_user_payment_methods(user_id)
for method in existing_methods:
if method.get('type') == 'paypal':
logger.info(f"User {user_id} already has PayPal payment method")
return RedirectResponse(
url="/dashboard/billing?error=PayPal is already added as a payment method",
status_code=302
)
# Construct callback URLs
base_url = get_base_url(request)
return_url = f"{base_url}/dashboard/billing/add-method/paypal/callback"
cancel_url = f"{base_url}/dashboard/billing?error=PayPal connection cancelled"
# Create vault setup token using payment service
result = await payment_service.initiate_paypal_vault_setup(user_id, return_url, cancel_url)
if not result['success']:
logger.error(f"Failed to create PayPal vault setup: {result.get('error')}")
return RedirectResponse(
url="/dashboard/billing?error=Failed to initialize PayPal connection",
status_code=302
)
logger.info(f"Initiating PayPal vault setup for user {user_id}")
return RedirectResponse(url=result['approval_url'], status_code=302)
@router.get("/dashboard/billing/add-method/paypal/callback")
async def dashboard_add_payment_method_paypal_callback(request: Request):
"""Handle PayPal vault setup callback"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
# Get query parameters
token = request.query_params.get('token')
error = request.query_params.get('error')
# Handle user cancellation
if error:
logger.info(f"PayPal vault setup cancelled by user {user_id}: {error}")
return RedirectResponse(
url="/dashboard/billing?error=PayPal connection cancelled",
status_code=302
)
# Validate setup token
if not token:
logger.error(f"PayPal callback missing setup token (user_id={user_id})")
return RedirectResponse(
url="/dashboard/billing?error=Invalid PayPal response",
status_code=302
)
try:
# Complete vault setup using payment service
result = await payment_service.complete_paypal_vault_setup(user_id, token)
if not result['success']:
logger.error(f"Failed to complete PayPal vault setup: {result.get('error')}")
return RedirectResponse(
url="/dashboard/billing?error=Failed to connect PayPal account",
status_code=302
)
logger.info(f"PayPal payment method added for user {user_id} (payment_token={result['payment_token_id']})")
return RedirectResponse(
url="/dashboard/billing?success=PayPal account connected successfully",
status_code=302
)
except Exception as e:
logger.error(f"PayPal callback error (user_id={user_id}): {str(e)}")
return RedirectResponse(
url="/dashboard/billing?error=Failed to connect PayPal account",
status_code=302
)
except Exception as e:
logger.error(f"PayPal callback error (user_id={user_id}): {str(e)}")
return RedirectResponse(
url="/dashboard/billing?error=Failed to connect PayPal account",
status_code=302
)
# Validate state token (CSRF protection)
session_state = request.session.get('paypal_oauth_state')
if not session_state:
logger.warning(f"PayPal OAuth callback with no session state (user_id={user_id})")
return RedirectResponse(
url="/dashboard/billing?error=Session expired, please try again",
status_code=302
)
if state != session_state:
logger.warning(f"PayPal OAuth state mismatch (user_id={user_id})")
return RedirectResponse(
url="/dashboard/billing?error=Invalid request (security check failed)",
status_code=302
)
# Clear state token from session
request.session.pop('paypal_oauth_state', None)
# Validate authorization code
if not code:
logger.error(f"PayPal OAuth callback missing authorization code (user_id={user_id})")
return RedirectResponse(
url="/dashboard/billing?error=Invalid PayPal response",
status_code=302
)
# Get PayPal settings
gateways = db.get_payment_gateway_settings()
paypal_settings = gateways.get('paypal', {})
client_id = paypal_settings.get('client_id', '').strip()
client_secret = paypal_settings.get('client_secret', '').strip()
is_sandbox = paypal_settings.get('sandbox', True)
if not client_id or not client_secret:
logger.error(f"PayPal OAuth callback but credentials not configured (user_id={user_id})")
return RedirectResponse(
url="/dashboard/billing?error=PayPal is not properly configured",
status_code=302
)
# Determine PayPal API URLs
if is_sandbox:
token_url = "https://api.sandbox.paypal.com/v1/oauth2/token"
userinfo_url = "https://api.sandbox.paypal.com/v1/identity/oauth2/userinfo?schema=openid"
else:
token_url = "https://api.paypal.com/v1/oauth2/token"
userinfo_url = "https://api.paypal.com/v1/identity/oauth2/userinfo?schema=openid"
# Construct callback URL (must match what was sent to PayPal)
base_url = get_base_url(request)
redirect_uri = f"{base_url}/dashboard/billing/add-method/paypal/callback"
try:
# Exchange authorization code for access token
import base64
auth_string = f"{client_id}:{client_secret}"
auth_bytes = auth_string.encode('utf-8')
auth_b64 = base64.b64encode(auth_bytes).decode('utf-8')
async with httpx.AsyncClient() as client:
# Token exchange request
token_response = await client.post(
token_url,
headers={
'Authorization': f'Basic {auth_b64}',
'Content-Type': 'application/x-www-form-urlencoded'
},
data={
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': redirect_uri
},
timeout=30.0
)
if token_response.status_code != 200:
logger.error(f"PayPal token exchange failed (user_id={user_id}): {token_response.status_code} {token_response.text}")
return RedirectResponse(
url="/dashboard/billing?error=Failed to connect PayPal account",
status_code=302
)
token_data = token_response.json()
access_token = token_data.get('access_token')
if not access_token:
logger.error(f"PayPal token response missing access_token (user_id={user_id})")
return RedirectResponse(
url="/dashboard/billing?error=Failed to connect PayPal account",
status_code=302
)
logger.info(f"PayPal access token obtained for user {user_id}")
# Fetch user profile
userinfo_response = await client.get(
userinfo_url,
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
},
timeout=30.0
)
if userinfo_response.status_code != 200:
logger.error(f"PayPal userinfo fetch failed (user_id={user_id}): {userinfo_response.status_code}")
return RedirectResponse(
url="/dashboard/billing?error=Failed to retrieve PayPal account information",
status_code=302
)
userinfo = userinfo_response.json()
paypal_user_id = userinfo.get('user_id')
paypal_email = userinfo.get('email')
paypal_name = userinfo.get('name', '')
if not paypal_user_id or not paypal_email:
logger.error(f"PayPal userinfo missing required fields (user_id={user_id})")
return RedirectResponse(
url="/dashboard/billing?error=Failed to retrieve PayPal account information",
status_code=302
)
logger.info(f"PayPal user profile retrieved for user {user_id}: {paypal_email}")
# Check for duplicate PayPal account
existing_methods = db.get_user_payment_methods(user_id)
for method in existing_methods:
if method.get('type') == 'paypal':
metadata = method.get('metadata', {})
if isinstance(metadata, str):
import json
metadata = json.loads(metadata)
existing_email = metadata.get('paypal_email')
existing_user_id = metadata.get('paypal_user_id')
if existing_email == paypal_email or existing_user_id == paypal_user_id:
logger.info(f"Duplicate PayPal account detected for user {user_id}")
return RedirectResponse(
url="/dashboard/billing?error=This PayPal account is already connected",
status_code=302
)
# Store payment method
is_default = len(existing_methods) == 0
metadata = {
'paypal_user_id': paypal_user_id,
'paypal_email': paypal_email,
'paypal_name': paypal_name,
'access_token': access_token,
'sandbox': is_sandbox
}
method_id = db.add_payment_method(
user_id=user_id,
method_type='paypal',
identifier=paypal_email,
is_default=is_default,
metadata=metadata
)
if method_id:
logger.info(f"PayPal payment method added for user {user_id} (method_id={method_id})")
return RedirectResponse(
url="/dashboard/billing?success=PayPal account connected successfully",
status_code=302
)
else:
logger.error(f"Failed to store PayPal payment method for user {user_id}")
return RedirectResponse(
url="/dashboard/billing?error=Failed to save payment method",
status_code=302
)
except httpx.TimeoutException as e:
logger.error(f"PayPal OAuth timeout (user_id={user_id}): {e}")
return RedirectResponse(
url="/dashboard/billing?error=Connection timeout, please try again",
status_code=302
)
except httpx.HTTPError as e:
logger.error(f"PayPal OAuth HTTP error (user_id={user_id}): {e}")
return RedirectResponse(
url="/dashboard/billing?error=Connection error, please try again",
status_code=302
)
except Exception as e:
logger.error(f"PayPal OAuth unexpected error (user_id={user_id}): {e}", exc_info=True)
return RedirectResponse(
url="/dashboard/billing?error=An error occurred while connecting PayPal",
status_code=302
)
@router.get("/dashboard/response-cache")
async def dashboard_response_cache(request: Request):
"""Response cache dashboard page"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
is_admin = request.session.get('role') == 'admin'
current_user_id = request.session.get('user_id')
from aisbf.cache import get_response_cache
try:
cache = get_response_cache()
if is_admin:
# Admin sees global stats
stats = cache.get_stats()
else:
# Regular users see their own personal cache impact
stats = cache.get_user_stats(current_user_id)
except Exception as e:
logger.error(f"Error getting response cache stats: {e}")
stats = {
'enabled': False,
'hits': 0,
'misses': 0,
'hit_rate': 0.0,
'size': 0,
'evictions': 0,
'backend': 'unknown',
'error': str(e)
}
return _templates.TemplateResponse(
request=request,
name="dashboard/response_cache.html",
context={
"request": request,
"session": request.session,
"stats": stats,
"is_admin": is_admin
}
)
@router.get("/dashboard/rate-limits")
async def dashboard_rate_limits(request: Request):
"""Rate limits dashboard page"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
return _templates.TemplateResponse(
request=request,
name="dashboard/rate_limits.html",
context={
"request": request,
"session": request.session
}
)
@router.get("/dashboard/rate-limits/data")
async def dashboard_rate_limits_data(request: Request):
"""Get adaptive rate limit statistics"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
from aisbf.providers import get_all_adaptive_rate_limiters
is_admin = request.session.get('role') == 'admin'
current_user_id = request.session.get('user_id')
try:
if is_admin:
# Admin sees all limiters
limiters = get_all_adaptive_rate_limiters()
else:
# Regular user sees ONLY their own configured providers
db = DatabaseRegistry.get_config_database()
user_providers = db.get_user_providers(current_user_id)
user_provider_ids = [p['provider_id'] for p in user_providers]
# Get all limiters for this user
all_limiters = get_all_adaptive_rate_limiters(current_user_id)
# Filter to only show providers the user has actually configured
limiters = {}
for provider_id, limiter in all_limiters.items():
if provider_id in user_provider_ids:
limiters[provider_id] = limiter
stats = {}
for provider_id, limiter in limiters.items():
stats[provider_id] = limiter.get_stats()
return JSONResponse(stats)
except Exception as e:
logger.error(f"Error getting rate limit stats: {e}")
return JSONResponse({
'error': str(e),
'providers': {}
})
@router.post("/dashboard/rate-limits/{provider_id}/reset")
async def dashboard_rate_limits_reset(request: Request, provider_id: str):
"""Reset adaptive rate limiter for a specific provider"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
is_admin = request.session.get('role') == 'admin'
current_user_id = request.session.get('user_id')
from aisbf.providers import get_all_adaptive_rate_limiters, get_adaptive_rate_limiter
try:
if is_admin:
# Admin can reset any limiter
limiters = get_all_adaptive_rate_limiters()
if provider_id in limiters:
limiters[provider_id].reset()
return JSONResponse({'success': True, 'message': f'Rate limiter for {provider_id} reset successfully'})
else:
return JSONResponse({'success': False, 'error': f'Provider {provider_id} not found'}, status_code=404)
else:
# Regular user can only reset limiters for providers THEY have configured
db = DatabaseRegistry.get_config_database()
user_providers = db.get_user_providers(current_user_id)
user_provider_ids = [p['provider_id'] for p in user_providers]
if provider_id not in user_provider_ids:
return JSONResponse({'success': False, 'error': f'You do not have permission to reset rate limiters for {provider_id}'}, status_code=403)
# Reset their user-specific limiter
user_limiter_key = f"user:{current_user_id}:{provider_id}"
limiters = get_all_adaptive_rate_limiters()
if user_limiter_key in limiters:
limiters[user_limiter_key].reset()
return JSONResponse({'success': True, 'message': f'Rate limiter for {provider_id} reset successfully'})
else:
return JSONResponse({'success': False, 'error': f'Rate limiter for {provider_id} not found'}, status_code=404)
except Exception as e:
logger.error(f"Error resetting rate limiter: {e}")
return JSONResponse({'success': False, 'error': str(e)}, status_code=500)
@router.post("/dashboard/response-cache/clear")
async def dashboard_response_cache_clear(request: Request):
"""Clear response cache"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
is_admin = request.session.get('role') == 'admin'
if not is_admin:
return JSONResponse({'success': False, 'error': 'Clearing cache is only available to administrators'}, status_code=403)
from aisbf.cache import get_response_cache
try:
cache = get_response_cache()
cache.clear()
return JSONResponse({'success': True, 'message': 'Response cache cleared'})
except Exception as e:
logger.error(f"Error clearing response cache: {e}")
return JSONResponse({'success': False, 'error': str(e)}, status_code=500)
@router.post("/dashboard/local-models/clear-cache")
async def dashboard_local_models_clear_cache(request: Request):
"""Delete one or all local HuggingFace model caches and unload in-memory models."""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
if request.session.get('role') != 'admin':
return JSONResponse({'success': False, 'error': 'Admin only'}, status_code=403)
body = await request.json()
model_id = body.get('model_id') # None means "clear all configured models"
model_type = body.get('model_type') # 'autoselect', 'condensation', 'nsfw', 'privacy', 'semantic'
from aisbf.config import config as cfg
internal = cfg.aisbf.internal_model if (cfg.aisbf and cfg.aisbf.internal_model) else {}
if model_id:
model_ids_to_clear = [model_id]
else:
model_ids_to_clear = [v for v in [
internal.get('autoselect_model_id'),
internal.get('condensation_model_id'),
internal.get('nsfw_classifier', 'michelleli99/NSFW_text_classifier'),
internal.get('privacy_classifier', 'iiiorg/piiranha-v1-detect-personal-information'),
internal.get('semantic_vectorization', 'sentence-transformers/all-MiniLM-L6-v2'),
] if v]
cleared = []
errors = []
# 1. Remove disk cache via huggingface_hub
try:
from huggingface_hub import scan_cache_dir
cache_info = scan_cache_dir()
for repo in cache_info.repos:
if repo.repo_id in model_ids_to_clear:
commit_hashes = [rev.commit_hash for rev in repo.revisions]
if commit_hashes:
delete_strategy = cache_info.delete_revisions(*commit_hashes)
delete_strategy.execute()
cleared.append(repo.repo_id)
except Exception as e:
errors.append(f"Cache deletion error: {e}")
# 2. Unload in-memory models
try:
from aisbf.classifier import content_classifier, semantic_classifier
should_reset_content = not model_id or model_type in ('nsfw', 'privacy')
should_reset_semantic = not model_id or model_type == 'semantic'
if should_reset_content:
content_classifier.reset()
if should_reset_semantic:
semantic_classifier.reset()
except Exception as e:
errors.append(f"Classifier reset error: {e}")
try:
should_reset_autoselect = not model_id or model_type == 'autoselect'
if should_reset_autoselect and autoselect_handler:
autoselect_handler.reset_internal_model()
except Exception as e:
errors.append(f"Autoselect handler reset error: {e}")
if errors:
return JSONResponse({'success': len(cleared) > 0, 'cleared': cleared, 'errors': errors})
return JSONResponse({'success': True, 'cleared': cleared, 'message': f'Cleared {len(cleared)} model(s) from cache'})
@router.get("/dashboard/docs", response_class=HTMLResponse)
async def dashboard_docs(request: Request):
"""Display documentation"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
# Try to find DOCUMENTATION.md in multiple locations
search_paths = [
Path.home() / '.aisbf' / 'DOCUMENTATION.md',
Path.home() / '.local' / 'share' / 'aisbf' / 'DOCUMENTATION.md',
Path('/usr/share/aisbf') / 'DOCUMENTATION.md',
Path(__file__).parent / 'DOCUMENTATION.md',
]
doc_path = None
for path in search_paths:
if path.exists():
doc_path = path
break
if doc_path and doc_path.exists():
with open(doc_path, encoding='utf-8') as f:
markdown_content = f.read()
# Convert markdown to HTML with extensions for better formatting
html_content = markdown.markdown(
markdown_content,
extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists', 'toc']
)
else:
html_content = "<p>Documentation file not found.</p>"
return _templates.TemplateResponse(
request=request,
name="dashboard/docs.html",
context={
"request": request,
"session": request.session,
"content": html_content,
"title": "Documentation"
}
)
@router.get("/dashboard/about", response_class=HTMLResponse)
async def dashboard_about(request: Request):
"""Display README/About"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
# Try to find README.md in multiple locations
search_paths = [
Path.home() / '.aisbf' / 'README.md',
Path.home() / '.local' / 'share' / 'aisbf' / 'README.md',
Path('/usr/share/aisbf') / 'README.md',
Path(__file__).parent / 'README.md',
]
readme_path = None
for path in search_paths:
if path.exists():
readme_path = path
break
if readme_path and readme_path.exists():
with open(readme_path, encoding='utf-8') as f:
markdown_content = f.read()
# Convert markdown to HTML with extensions for better formatting
html_content = markdown.markdown(
markdown_content,
extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists']
)
# Rewrite DOCUMENTATION.md links (relative or absolute) to /dashboard/docs
html_content = re.sub(
r'href="(?:https?://[^"]*?/)?DOCUMENTATION\.md#([^"]*)"',
r'href="/dashboard/docs#\1"',
html_content
)
html_content = re.sub(
r'href="(?:https?://[^"]*?/)?DOCUMENTATION\.md"',
'href="/dashboard/docs"',
html_content
)
else:
html_content = "<p>README file not found.</p>"
return _templates.TemplateResponse(
request=request,
name="dashboard/docs.html",
context={
"request": request,
"session": request.session,
"content": html_content,
"title": "About"
}
)
@router.get("/dashboard/license", response_class=HTMLResponse)
async def dashboard_license(request: Request):
"""Display License"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
# Try to find LICENSE.txt in multiple locations
search_paths = [
Path.home() / '.aisbf' / 'LICENSE.txt',
Path.home() / '.local' / 'share' / 'aisbf' / 'LICENSE.txt',
Path('/usr/share/aisbf') / 'LICENSE.txt',
Path(__file__).parent / 'LICENSE.txt',
]
license_path = None
for path in search_paths:
if path.exists():
license_path = path
break
if license_path and license_path.exists():
with open(license_path, encoding='utf-8') as f:
content = f.read()
# Convert to HTML with pre tags to preserve formatting
html_content = f"<pre style='white-space: pre-wrap; word-wrap: break-word; background: #0f3460; padding: 20px; border-radius: 6px; color: #e0e0e0; font-family: inherit;'>{content}</pre>"
else:
html_content = "<p>License file not found.</p>"
return _templates.TemplateResponse(
request=request,
name="dashboard/docs.html",
context={
"request": request,
"session": request.session,
"content": html_content,
"title": "License"
}
)
@router.get("/dashboard/blocked", response_class=HTMLResponse)
async def blocked_page(request: Request):
"""Display blocked access page."""
return _templates.TemplateResponse(request=request, name="blocked.html", context={"request": request})
from fastapi import APIRouter, Request, Form, Query, HTTPException
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, Response
from typing import Optional
import logging, json, os, threading, time
from pathlib import Path
from aisbf.database import DatabaseRegistry
from aisbf.app.templates import url_for, get_base_url
from aisbf.routes.auth import require_dashboard_auth, require_api_auth, require_admin
router = APIRouter()
_config = None
_templates = None
def init(config, templates):
global _config, _templates
_config = config
_templates = templates
logger = logging.getLogger(__name__)
import secrets
# Global storage for pending OAuth2 callbacks (for localhost flow)
_pending_oauth2_callbacks = {}
_oauth2_callback_server = None
@router.get("/dashboard/oauth2/callback")
@router.get("/dashboard/oauth2/callback/{user_id}/{provider}")
async def dashboard_oauth2_callback(
request: Request,
code: str = Query(None),
state: str = Query(None),
error: str = Query(None),
user_id: str = None,
provider: str = None
):
"""Handle OAuth2 callback redirected from localhost or browser extension."""
try:
if error:
logger.error(f"OAuth2 callback error: {error}")
return HTMLResponse(content=f"<html><body><h1>Authentication Error</h1><p>Error: {error}</p><p><a href='/dashboard/providers'>Return to Dashboard</a></p></body></html>", status_code=400)
if not code:
return HTMLResponse(content="<html><body><h1>Authentication Error</h1><p>No authorization code received</p><p><a href='/dashboard/providers'>Return to Dashboard</a></p></body></html>", status_code=400)
_pending_oauth2_callbacks[state] = {
'code': code, 'state': state, 'error': error,
'timestamp': time.time(), 'user_id': user_id, 'provider': provider
}
try:
request.session['oauth2_code'] = code
request.session['oauth2_state'] = state
if user_id:
request.session['oauth2_user_id'] = user_id
if provider:
request.session['oauth2_provider'] = provider
except Exception:
pass
logger.info(f"OAuth2 callback received - User: {user_id}, Provider: {provider}, State: {state[:10] if state else 'None'}..., Code: {code[:10]}...")
return HTMLResponse(content="""
<html><head><title>Authentication Successful</title>
<style>body{font-family:Arial,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:white;}.container{text-align:center;padding:40px;background:rgba(255,255,255,0.1);border-radius:10px;}</style>
</head><body><div class="container"><h1>✓ Authentication Successful</h1><p>You can close this window and return to the dashboard.</p><p><a href="/dashboard/providers" style="color:#fff">Return to Dashboard</a></p></div>
<script>setTimeout(()=>window.close(),3000);</script></body></html>
""")
except Exception as e:
logger.error(f"Error handling OAuth2 callback: {e}")
return HTMLResponse(content=f"<html><body><h1>Authentication Error</h1><p>Error: {str(e)}</p><p><a href='/dashboard/providers'>Return to Dashboard</a></p></body></html>", status_code=500)
def _start_localhost_callback_server():
"""Start a temporary HTTP server on port 54545 to catch OAuth2 callbacks."""
global _oauth2_callback_server
if _oauth2_callback_server is not None:
logger.info("Localhost callback server already running")
return
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
class CallbackHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == '/callback':
query_params = parse_qs(parsed.query)
code = query_params.get('code', [None])[0]
state = query_params.get('state', [None])[0]
error = query_params.get('error', [None])[0]
if state:
_pending_oauth2_callbacks[state] = {'code': code, 'state': state, 'error': error, 'timestamp': time.time()}
_pending_oauth2_callbacks['latest'] = {'code': code, 'state': state, 'error': error, 'timestamp': time.time()}
if error:
response_html = f"<html><body style='font-family:Arial;text-align:center;padding:50px;'><h1 style='color:#e74c3c;'>✗ Authentication Error</h1><p>Error: {error}</p><p>You can close this window.</p></body></html>"
self.send_response(400)
else:
response_html = "<html><body style='font-family:Arial;text-align:center;padding:50px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:white;height:100vh;margin:0;display:flex;justify-content:center;align-items:center;'><div style='background:rgba(255,255,255,0.1);padding:40px;border-radius:10px;'><h1>✓ Authentication Successful</h1><p>You can close this window and return to the dashboard.</p></div><script>setTimeout(()=>window.close(),3000);</script></body></html>"
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(response_html.encode())
else:
self.send_response(404)
self.end_headers()
def run_server():
global _oauth2_callback_server
try:
_oauth2_callback_server = HTTPServer(('127.0.0.1', 54545), CallbackHandler)
logger.info("Started localhost OAuth2 callback server on port 54545")
_oauth2_callback_server.serve_forever()
except OSError as e:
if "Address already in use" in str(e):
logger.warning("Port 54545 already in use - another callback server may be running")
else:
logger.error(f"Failed to start callback server: {e}")
except Exception as e:
logger.error(f"Callback server error: {e}")
finally:
_oauth2_callback_server = None
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
time.sleep(0.1)
def _stop_localhost_callback_server():
"""Stop the localhost callback server."""
global _oauth2_callback_server
if _oauth2_callback_server:
_oauth2_callback_server.shutdown()
_oauth2_callback_server = None
logger.info("Stopped localhost OAuth2 callback server")
# Claude OAuth2 authentication endpoints
@router.post("/dashboard/claude/auth/start")
async def dashboard_claude_auth_start(request: Request):
"""Start Claude OAuth2 authentication flow"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.claude_credentials.json')
if not provider_key:
return JSONResponse(status_code=400, content={"success": False, "error": "Provider key is required"})
from aisbf.auth.claude import ClaudeAuth
auth = ClaudeAuth(credentials_file=credentials_file, skip_initial_load=True)
verifier, challenge = auth._generate_pkce()
state = secrets.token_urlsafe(32)
request.session['oauth2_verifier'] = verifier
request.session['oauth2_state'] = state
request.session['oauth2_provider'] = provider_key
request.session['oauth2_credentials_file'] = credentials_file
client_host = request.client.host if request.client else None
is_local_access = client_host in ['127.0.0.1', '::1', 'localhost']
request_host = request.headers.get('host', '').split(':')[0]
is_localhost_request = request_host in ['127.0.0.1', 'localhost', '::1']
has_proxy_headers = ('X-Forwarded-For' in request.headers or 'X-Forwarded-Host' in request.headers or 'X-Real-IP' in request.headers)
use_extension = not (is_local_access or is_localhost_request) or has_proxy_headers
if not use_extension:
_start_localhost_callback_server()
logger.info("Started localhost callback server for direct OAuth2 flow")
auth_params = {
"code": "true", "client_id": auth.CLIENT_ID, "response_type": "code",
"code_challenge": challenge, "code_challenge_method": "S256",
"redirect_uri": auth.REDIRECT_URI,
"scope": "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
"state": state
}
auth_url = f"{auth.AUTH_URL}?{'&'.join(f'{k}={v}' for k, v in auth_params.items())}"
return JSONResponse({
"success": True, "auth_url": auth_url, "use_extension": use_extension,
"message": "Please complete authentication in the browser window" if use_extension else "Authentication will use direct localhost callback"
})
except Exception as e:
logger.error(f"Error starting Claude auth: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.post("/dashboard/claude/auth/complete")
async def dashboard_claude_auth_complete(request: Request):
"""Complete Claude OAuth2 authentication using the code from callback"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
state = request.session.get('oauth2_state')
verifier = request.session.get('oauth2_verifier')
credentials_file = request.session.get('oauth2_credentials_file', '~/.claude_credentials.json')
code = request.session.get('oauth2_code')
if not code and state and state in _pending_oauth2_callbacks:
callback_data = _pending_oauth2_callbacks[state]
if time.time() - callback_data.get('timestamp', 0) < 300:
code = callback_data.get('code')
if callback_data.get('error'):
return JSONResponse(status_code=400, content={"success": False, "error": f"OAuth2 error: {callback_data['error']}"})
logger.info(f"Using code from global callback storage for state {state[:10]}...: {code[:10] if code else 'None'}...")
if not code or not verifier:
return JSONResponse(status_code=400, content={"success": False, "error": "No authorization code found. Please restart authentication."})
from aisbf.auth.claude import ClaudeAuth
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
save_callback = None
if not is_config_admin:
provider_key = request.session.get('oauth2_provider')
def save_callback(creds):
try:
db = DatabaseRegistry.get_config_database()
if db and current_user_id and provider_key:
db.save_user_oauth2_credentials(user_id=current_user_id, provider_id=provider_key, auth_type='claude_oauth2', credentials=creds)
logger.info(f"ClaudeOAuth2: Saved credentials to database for user {current_user_id}")
except Exception as e:
logger.error(f"ClaudeOAuth2: Failed to save credentials to database: {e}")
raise
auth = ClaudeAuth(credentials_file=credentials_file, skip_initial_load=True, save_callback=save_callback)
success = await auth.exchange_code_for_tokens(code, state, verifier)
if success:
if not is_config_admin:
credentials_path = Path(credentials_file).expanduser()
credentials_path.unlink(missing_ok=True)
request.session.pop('oauth2_code', None)
request.session.pop('oauth2_verifier', None)
request.session.pop('oauth2_state', None)
request.session.pop('oauth2_provider', None)
request.session.pop('oauth2_credentials_file', None)
if state:
_pending_oauth2_callbacks.pop(state, None)
return JSONResponse({"success": True, "message": "Authentication completed successfully"})
else:
return JSONResponse(status_code=400, content={"success": False, "error": "Token exchange failed. If you see rate_limit_error, please wait 1-2 minutes before trying again."})
except Exception as e:
logger.error(f"Error completing Claude auth: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.get("/dashboard/claude/auth/callback-status")
async def dashboard_claude_auth_callback_status(request: Request):
"""Check if OAuth2 callback has been received (for localhost flow)"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
expected_state = request.session.get('oauth2_state')
if expected_state and expected_state in _pending_oauth2_callbacks:
callback_data = _pending_oauth2_callbacks[expected_state]
if time.time() - callback_data.get('timestamp', 0) < 300:
if callback_data.get('error'):
return JSONResponse({"received": True, "error": callback_data['error']})
elif callback_data.get('code'):
return JSONResponse({"received": True, "has_code": True})
if request.session.get('oauth2_code'):
return JSONResponse({"received": True, "has_code": True})
now = time.time()
stale_states = [k for k, v in _pending_oauth2_callbacks.items()
if k != 'latest' and now - v.get('timestamp', 0) > 600]
for stale in stale_states:
_pending_oauth2_callbacks.pop(stale, None)
return JSONResponse({"received": False})
@router.post("/dashboard/claude/auth/status")
async def dashboard_claude_auth_status(request: Request):
"""Check Claude authentication status"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.claude_credentials.json')
if not provider_key:
return JSONResponse(status_code=400, content={"authenticated": False, "error": "Provider key is required"})
from aisbf.auth.claude import ClaudeAuth
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
if not is_config_admin:
try:
db = DatabaseRegistry.get_config_database()
if db and current_user_id:
db_creds = db.get_user_oauth2_credentials(user_id=current_user_id, provider_id=provider_key, auth_type='claude_oauth2')
if db_creds and db_creds.get('credentials'):
tokens = db_creds['credentials'].get('tokens', {})
if tokens.get('access_token'):
return JSONResponse({"authenticated": True, "email": db_creds['credentials'].get('email', 'unknown')})
except Exception as e:
logger.warning(f"ClaudeOAuth2: Failed to check database credentials: {e}")
auth = ClaudeAuth(credentials_file=credentials_file)
if auth.tokens:
expires_at = auth.tokens.get('expires_at', 0)
if time.time() < (expires_at - 300):
return JSONResponse({"authenticated": True, "expires_in": expires_at - time.time()})
else:
if await auth.refresh_token():
return JSONResponse({"authenticated": True, "expires_in": auth.tokens.get('expires_at', 0) - time.time()})
else:
return JSONResponse({"authenticated": False})
else:
return JSONResponse({"authenticated": False})
except Exception as e:
logger.error(f"Error checking Claude auth status: {e}")
return JSONResponse(status_code=500, content={"authenticated": False, "error": str(e)})
# Kilo OAuth2 authentication endpoints
@router.post("/dashboard/kilo/auth/start")
async def dashboard_kilo_auth_start(request: Request):
"""Start Kilo OAuth2 Device Authorization Grant flow"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.kilo_credentials.json')
if not provider_key:
return JSONResponse(status_code=400, content={"success": False, "error": "Provider key is required"})
from aisbf.auth.kilo import KiloOAuth2
auth = KiloOAuth2(credentials_file=credentials_file, skip_initial_load=True)
device_auth = await auth.initiate_device_auth()
if not device_auth:
return JSONResponse(status_code=500, content={"success": False, "error": "Failed to initiate device authorization"})
request.session['kilo_device_code'] = device_auth['code']
request.session['kilo_provider'] = provider_key
request.session['kilo_credentials_file'] = credentials_file
request.session['kilo_expires_at'] = time.time() + device_auth['expiresIn']
return JSONResponse({
"success": True, "user_code": device_auth['code'],
"verification_uri": device_auth['verificationUrl'],
"expires_in": device_auth['expiresIn'], "interval": 3,
"message": f"Please visit {device_auth['verificationUrl']} and enter code: {device_auth['code']}"
})
except Exception as e:
logger.error(f"Error starting Kilo auth: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.post("/dashboard/kilo/auth/poll")
async def dashboard_kilo_auth_poll(request: Request):
"""Poll Kilo OAuth2 device authorization status"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
device_code = request.session.get('kilo_device_code')
credentials_file = request.session.get('kilo_credentials_file', '~/.kilo_credentials.json')
expires_at = request.session.get('kilo_expires_at', 0)
if not device_code:
return JSONResponse(status_code=400, content={"success": False, "status": "error", "error": "No device authorization in progress"})
if time.time() > expires_at:
for k in ('kilo_device_code', 'kilo_provider', 'kilo_credentials_file', 'kilo_expires_at'):
request.session.pop(k, None)
return JSONResponse({"success": False, "status": "expired", "error": "Device authorization expired"})
from aisbf.auth.kilo import KiloOAuth2
auth = KiloOAuth2(credentials_file=credentials_file, skip_initial_load=True)
result = await auth.poll_device_auth(device_code)
if result['status'] == 'approved':
token = result.get('token')
user_email = result.get('userEmail')
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
if token:
credentials = {
"type": "oauth", "access": token, "refresh": token,
"expires": int(time.time()) + (365 * 24 * 60 * 60), "userEmail": user_email
}
if not is_config_admin:
try:
db = DatabaseRegistry.get_config_database()
provider_key = request.session.get('kilo_provider')
if db and current_user_id and provider_key:
db.save_user_oauth2_credentials(user_id=current_user_id, provider_id=provider_key, auth_type='kilo_oauth2', credentials=credentials)
logger.info(f"KiloOAuth2: Saved credentials to database for user {current_user_id}")
except Exception as e:
logger.error(f"KiloOAuth2: Failed to save credentials to database: {e}")
else:
auth._save_credentials(credentials)
logger.info(f"KiloOAuth2: Saved credentials to file for {user_email}")
for k in ('kilo_device_code', 'kilo_provider', 'kilo_credentials_file', 'kilo_expires_at'):
request.session.pop(k, None)
return JSONResponse({"success": True, "status": "completed", "message": "Authentication completed successfully"})
elif result['status'] == 'pending':
return JSONResponse({"success": True, "status": "pending", "message": "Waiting for user authorization"})
elif result['status'] in ('denied', 'expired'):
for k in ('kilo_device_code', 'kilo_provider', 'kilo_credentials_file', 'kilo_expires_at'):
request.session.pop(k, None)
return JSONResponse({"success": False, "status": result['status'], "error": f"User {result['status']} authorization"})
elif result['status'] == 'slow_down':
return JSONResponse({"success": True, "status": "slow_down", "message": "Polling too frequently, slowing down"})
else:
return JSONResponse({"success": False, "status": "error", "error": result.get('error', 'Unknown error')})
except Exception as e:
logger.error(f"Error polling Kilo auth: {e}")
return JSONResponse(status_code=500, content={"success": False, "status": "error", "error": str(e)})
@router.post("/dashboard/kilo/auth/status")
async def dashboard_kilo_auth_status(request: Request):
"""Check Kilo authentication status"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.kilo_credentials.json')
if not provider_key:
return JSONResponse(status_code=400, content={"authenticated": False, "error": "Provider key is required"})
from aisbf.auth.kilo import KiloOAuth2
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
if not is_config_admin:
try:
db = DatabaseRegistry.get_config_database()
if db and current_user_id:
db_creds = db.get_user_oauth2_credentials(user_id=current_user_id, provider_id=provider_key, auth_type='kilo_oauth2')
if db_creds and db_creds.get('credentials'):
creds = db_creds['credentials']
expires_at = creds.get('expires', 0)
if time.time() < expires_at:
return JSONResponse({"authenticated": True, "expires_in": max(0, expires_at - time.time()), "email": creds.get('userEmail', 'unknown')})
except Exception as e:
logger.warning(f"KiloOAuth2: Failed to check database credentials: {e}")
auth = KiloOAuth2(credentials_file=credentials_file)
if auth.is_authenticated():
token = await auth.get_valid_token()
if token:
expires_at = auth.credentials.get('expires', 0)
return JSONResponse({"authenticated": True, "expires_in": max(0, expires_at - time.time()), "email": auth.credentials.get('userEmail')})
return JSONResponse({"authenticated": False})
except Exception as e:
logger.error(f"Error checking Kilo auth status: {e}")
return JSONResponse(status_code=500, content={"authenticated": False, "error": str(e)})
@router.post("/dashboard/kilo/auth/logout")
async def dashboard_kilo_auth_logout(request: Request):
"""Logout from Kilo OAuth2"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.kilo_credentials.json')
if not provider_key:
return JSONResponse(status_code=400, content={"success": False, "error": "Provider key is required"})
from aisbf.auth.kilo import KiloOAuth2
auth = KiloOAuth2(credentials_file=credentials_file, skip_initial_load=True)
auth.logout()
return JSONResponse({"success": True, "message": "Logged out successfully"})
except Exception as e:
logger.error(f"Error logging out from Kilo: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
# Codex OAuth2 authentication endpoints
@router.post("/dashboard/codex/auth/start")
async def dashboard_codex_auth_start(request: Request):
"""Start Codex OAuth2 Device Authorization Grant flow"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.aisbf/codex_credentials.json')
issuer = data.get('issuer', 'https://auth.openai.com')
if not provider_key:
return JSONResponse(status_code=400, content={"success": False, "error": "Provider key is required"})
from aisbf.auth.codex import CodexOAuth2
auth = CodexOAuth2(credentials_file=credentials_file, issuer=issuer, skip_initial_load=True)
device_info = await auth.request_device_code_flow()
request.session['codex_device_auth_id'] = device_info.get('device_auth_id')
request.session['codex_user_code'] = device_info.get('user_code')
request.session['codex_provider'] = provider_key
request.session['codex_credentials_file'] = credentials_file
request.session['codex_issuer'] = issuer
request.session['codex_expires_at'] = time.time() + device_info.get('expires_in', 900)
return JSONResponse({
"success": True, "user_code": device_info.get('user_code'),
"verification_uri": device_info.get('verification_uri'),
"expires_in": device_info.get('expires_in', 900),
"interval": device_info.get('interval', 5),
"message": f"Please visit {device_info.get('verification_uri')} and enter code: {device_info.get('user_code')}"
})
except Exception as e:
logger.error(f"Error starting Codex auth: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.post("/dashboard/codex/auth/poll")
async def dashboard_codex_auth_poll(request: Request):
"""Poll Codex OAuth2 device authorization status"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
device_auth_id = request.session.get('codex_device_auth_id')
user_code = request.session.get('codex_user_code')
if not device_auth_id or not user_code:
return JSONResponse({"success": False, "status": "error", "error": "No device authorization in progress. Please start authentication again."})
expires_at = request.session.get('codex_expires_at', 0)
if time.time() > expires_at:
for k in ('codex_device_auth_id', 'codex_user_code', 'codex_provider', 'codex_credentials_file', 'codex_issuer', 'codex_expires_at'):
request.session.pop(k, None)
return JSONResponse({"success": False, "status": "expired", "error": "Device authorization expired"})
credentials_file = request.session.get('codex_credentials_file', '~/.aisbf/codex_credentials.json')
issuer = request.session.get('codex_issuer', 'https://auth.openai.com')
from aisbf.auth.codex import CodexOAuth2
auth = CodexOAuth2(credentials_file=credentials_file, issuer=issuer, skip_initial_load=True)
auth._device_auth_id = device_auth_id
auth._device_user_code = user_code
result = await auth.poll_device_code_completion()
if result['status'] == 'approved':
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
if not is_config_admin:
try:
db = DatabaseRegistry.get_config_database()
provider_key = request.session.get('codex_provider')
if db and current_user_id and provider_key:
credentials_path = Path(credentials_file).expanduser()
if credentials_path.exists():
with open(credentials_path, 'r') as f:
db_credentials = json.load(f)
db.save_user_oauth2_credentials(user_id=current_user_id, provider_id=provider_key, auth_type='codex_oauth2', credentials=db_credentials)
logger.info(f"CodexOAuth2: Saved credentials to database for user {current_user_id}")
credentials_path.unlink(missing_ok=True)
except Exception as e:
logger.error(f"CodexOAuth2: Failed to save credentials to database: {e}")
for k in ('codex_device_auth_id', 'codex_user_code', 'codex_provider', 'codex_credentials_file', 'codex_issuer', 'codex_expires_at'):
request.session.pop(k, None)
return JSONResponse({"success": True, "status": "approved", "message": "Authentication completed successfully", "new_endpoint": "https://chatgpt.com/backend-api/codex"})
elif result['status'] == 'pending':
return JSONResponse({"success": True, "status": "pending", "message": "Waiting for user authorization"})
elif result['status'] in ('denied', 'expired'):
for k in ('codex_device_auth_id', 'codex_user_code', 'codex_provider', 'codex_credentials_file', 'codex_issuer', 'codex_expires_at'):
request.session.pop(k, None)
return JSONResponse({"success": False, "status": result['status'], "error": f"User {result['status']} authorization"})
else:
return JSONResponse({"success": False, "status": "error", "error": result.get('error', 'Unknown error')})
except Exception as e:
logger.error(f"Error polling Codex auth: {e}")
return JSONResponse(status_code=500, content={"success": False, "status": "error", "error": str(e)})
@router.post("/dashboard/codex/auth/status")
async def dashboard_codex_auth_status(request: Request):
"""Check Codex authentication status"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.aisbf/codex_credentials.json')
if not provider_key:
return JSONResponse(status_code=400, content={"authenticated": False, "error": "Provider key is required"})
from aisbf.auth.codex import CodexOAuth2
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
if not is_config_admin:
try:
db = DatabaseRegistry.get_config_database()
if db and current_user_id:
db_creds = db.get_user_oauth2_credentials(user_id=current_user_id, provider_id=provider_key, auth_type='codex_oauth2')
if db_creds and db_creds.get('credentials'):
tokens = db_creds['credentials'].get('tokens', {})
if tokens.get('access_token'):
return JSONResponse({"authenticated": True, "email": db_creds['credentials'].get('email', 'unknown')})
except Exception as e:
logger.warning(f"CodexOAuth2: Failed to check database credentials: {e}")
auth = CodexOAuth2(credentials_file=credentials_file)
if auth.is_authenticated():
token = await auth.get_valid_token_with_refresh()
if token:
return JSONResponse({"authenticated": True, "email": auth.get_user_email()})
return JSONResponse({"authenticated": False})
except Exception as e:
logger.error(f"Error checking Codex auth status: {e}")
return JSONResponse(status_code=500, content={"authenticated": False, "error": str(e)})
@router.post("/dashboard/codex/auth/logout")
async def dashboard_codex_auth_logout(request: Request):
"""Logout from Codex OAuth2"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.aisbf/codex_credentials.json')
if not provider_key:
return JSONResponse(status_code=400, content={"success": False, "error": "Provider key is required"})
from aisbf.auth.codex import CodexOAuth2
auth = CodexOAuth2(credentials_file=credentials_file)
auth.logout()
return JSONResponse({"success": True, "message": "Logged out successfully"})
except Exception as e:
logger.error(f"Error logging out from Codex: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
# Qwen OAuth2 authentication endpoints
def _save_qwen_credentials(credentials_file: str, token_response: dict) -> None:
"""Save Qwen OAuth2 credentials to file"""
from datetime import datetime as _dt
try:
expires_in = token_response.get("expires_in", 7200)
expires_in_ms = max(expires_in * 1000, 3600000)
credentials = {
"access_token": token_response["access_token"],
"refresh_token": token_response.get("refresh_token"),
"token_type": token_response.get("token_type", "Bearer"),
"resource_url": token_response.get("resource_url"),
"expiry_date": int(time.time() * 1000) + expires_in_ms,
"last_refresh": _dt.utcnow().isoformat() + "Z",
}
cred_path = Path(credentials_file).expanduser()
cred_path.parent.mkdir(parents=True, exist_ok=True)
import tempfile
with tempfile.NamedTemporaryFile(mode='w', dir=cred_path.parent, delete=False) as f:
json.dump(credentials, f, indent=2)
temp_path = f.name
os.rename(temp_path, cred_path)
os.chmod(cred_path, 0o600)
logger.info(f"QwenOAuth2: Saved credentials to {credentials_file}")
except Exception as e:
logger.error(f"QwenOAuth2: Failed to save credentials: {e}")
raise
@router.post("/dashboard/qwen/auth/start")
async def dashboard_qwen_auth_start(request: Request):
"""Start Qwen OAuth2 Device Authorization Grant flow"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.aisbf/qwen_credentials.json')
if not provider_key:
return JSONResponse(status_code=400, content={"success": False, "error": "Provider key is required"})
from aisbf.auth.qwen import QwenOAuth2
auth = QwenOAuth2(credentials_file=credentials_file, skip_initial_load=True)
logger.info(f"QwenOAuth2: Requesting device code for provider: {provider_key}")
device_info = await auth.request_device_code()
if not device_info:
return JSONResponse(status_code=500, content={"success": False, "error": "Failed to initiate device authorization"})
logger.info(f"QwenOAuth2: Device code obtained: {device_info.get('user_code')}")
request.session['qwen_device_code'] = device_info['device_code']
request.session['qwen_code_verifier'] = device_info['code_verifier']
request.session['qwen_provider'] = provider_key
request.session['qwen_credentials_file'] = credentials_file
request.session['qwen_expires_at'] = time.time() + device_info['expires_in']
return JSONResponse({
"success": True, "user_code": device_info['user_code'],
"verification_uri": device_info['verification_uri_complete'],
"expires_in": device_info['expires_in'], "interval": device_info['interval'],
"message": f"Please visit {device_info['verification_uri_complete']} and enter code: {device_info['user_code']}"
})
except Exception as e:
logger.error(f"Error starting Qwen auth: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.post("/dashboard/qwen/auth/poll")
async def dashboard_qwen_auth_poll(request: Request):
"""Poll Qwen OAuth2 device authorization status"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
device_code = request.session.get('qwen_device_code')
code_verifier = request.session.get('qwen_code_verifier')
credentials_file = request.session.get('qwen_credentials_file', '~/.aisbf/qwen_credentials.json')
expires_at = request.session.get('qwen_expires_at', 0)
if not device_code:
return JSONResponse(status_code=400, content={"success": False, "status": "error", "error": "No device authorization in progress"})
if time.time() > expires_at:
for k in ('qwen_device_code', 'qwen_code_verifier', 'qwen_provider', 'qwen_credentials_file', 'qwen_expires_at'):
request.session.pop(k, None)
return JSONResponse({"success": False, "status": "expired", "error": "Device authorization expired"})
from aisbf.auth.qwen import QwenOAuth2
auth = QwenOAuth2(credentials_file=credentials_file, skip_initial_load=True)
result = await auth.poll_device_token(device_code, code_verifier)
if result and result.get("access_token"):
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
_save_qwen_credentials(credentials_file, result)
if not is_config_admin:
try:
db = DatabaseRegistry.get_config_database()
provider_key = request.session.get('qwen_provider')
if db and current_user_id and provider_key:
credentials_path = Path(credentials_file).expanduser()
if credentials_path.exists():
with open(credentials_path, 'r') as f:
db_credentials = json.load(f)
db.save_user_oauth2_credentials(user_id=current_user_id, provider_id=provider_key, auth_type='qwen_oauth2', credentials=db_credentials)
logger.info(f"QwenOAuth2: Saved credentials to database for user {current_user_id}")
credentials_path.unlink(missing_ok=True)
except Exception as e:
logger.error(f"QwenOAuth2: Failed to save credentials to database: {e}")
for k in ('qwen_device_code', 'qwen_code_verifier', 'qwen_provider', 'qwen_credentials_file', 'qwen_expires_at'):
request.session.pop(k, None)
return JSONResponse({"success": True, "status": "completed", "message": "Authentication completed successfully"})
elif result is None:
return JSONResponse({"success": True, "status": "pending", "message": "Waiting for user authorization. Please approve the device on your Qwen account."})
else:
return JSONResponse({"success": True, "status": "pending", "message": "Waiting for user authorization"})
except Exception as e:
error_msg = str(e).lower()
if "authorization_pending" in error_msg or "pending" in error_msg:
return JSONResponse({"success": True, "status": "pending", "message": "Waiting for user authorization. Please approve the device on your Qwen account."})
elif "slow_down" in error_msg or "429" in error_msg:
return JSONResponse({"success": True, "status": "slow_down", "message": "Polling too frequently, slowing down"})
elif "expired" in error_msg:
for k in ('qwen_device_code', 'qwen_code_verifier', 'qwen_provider', 'qwen_credentials_file', 'qwen_expires_at'):
request.session.pop(k, None)
return JSONResponse({"success": False, "status": "expired", "error": "Device authorization expired"})
else:
logger.error(f"Error polling Qwen auth: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"success": False, "status": "error", "error": str(e)})
@router.post("/dashboard/qwen/auth/status")
async def dashboard_qwen_auth_status(request: Request):
"""Check Qwen authentication status"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.aisbf/qwen_credentials.json')
if not provider_key:
return JSONResponse(status_code=400, content={"authenticated": False, "error": "Provider key is required"})
from aisbf.auth.qwen import QwenOAuth2
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
if not is_config_admin:
try:
db = DatabaseRegistry.get_config_database()
if db and current_user_id:
db_creds = db.get_user_oauth2_credentials(user_id=current_user_id, provider_id=provider_key, auth_type='qwen_oauth2')
if db_creds and db_creds.get('credentials'):
creds = db_creds['credentials']
access_token = creds.get('access_token')
expiry_date = creds.get('expiry_date', 0)
if access_token and time.time() * 1000 < expiry_date:
return JSONResponse({"authenticated": True, "expires_in": max(0, (expiry_date - int(time.time() * 1000)) / 1000)})
except Exception as e:
logger.warning(f"QwenOAuth2: Failed to check database credentials: {e}")
auth = QwenOAuth2(credentials_file=credentials_file)
if auth.is_authenticated():
token = await auth.get_valid_token_with_refresh()
if token:
expiry_date = auth.credentials.get('expiry_date', 0)
return JSONResponse({"authenticated": True, "expires_in": max(0, (expiry_date - int(time.time() * 1000)) / 1000)})
return JSONResponse({"authenticated": False})
except Exception as e:
logger.error(f"Error checking Qwen auth status: {e}")
return JSONResponse(status_code=500, content={"authenticated": False, "error": str(e)})
@router.post("/dashboard/qwen/auth/logout")
async def dashboard_qwen_auth_logout(request: Request):
"""Logout from Qwen OAuth2"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.aisbf/qwen_credentials.json')
if not provider_key:
return JSONResponse(status_code=400, content={"success": False, "error": "Provider key is required"})
from aisbf.auth.qwen import QwenOAuth2
auth = QwenOAuth2(credentials_file=credentials_file)
auth.clear_credentials()
current_user_id = request.session.get('user_id')
if current_user_id:
try:
db = DatabaseRegistry.get_config_database()
if db:
db.delete_user_oauth2_credentials(current_user_id, provider_key, 'qwen_oauth2')
except Exception as e:
logger.warning(f"QwenOAuth2: Failed to clear database credentials: {e}")
for k in ('qwen_device_code', 'qwen_code_verifier', 'qwen_provider', 'qwen_credentials_file', 'qwen_expires_at'):
request.session.pop(k, None)
return JSONResponse({"success": True, "message": "Logged out successfully"})
except Exception as e:
logger.error(f"Error logging out from Qwen: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.get("/dashboard/extension/download")
async def dashboard_extension_download(request: Request):
"""Download the OAuth2 redirect extension as a ZIP file"""
from fastapi.responses import FileResponse, Response
import zipfile, io
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
extension_zip = Path(__file__).parent.parent.parent.parent / 'static' / 'aisbf-oauth2-extension.zip'
if not extension_zip.exists():
extension_dir = Path(__file__).parent.parent.parent.parent / 'static' / 'extension'
if not extension_dir.exists():
return JSONResponse(status_code=404, content={"error": "Extension files not found"})
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
for fp in extension_dir.rglob('*'):
if fp.is_file() and not fp.name.endswith('.sh'):
zf.write(fp, fp.relative_to(extension_dir))
zip_buffer.seek(0)
return Response(content=zip_buffer.getvalue(), media_type="application/zip",
headers={"Content-Disposition": "attachment; filename=aisbf-oauth2-extension.zip"})
return FileResponse(path=extension_zip, media_type="application/zip",
filename="aisbf-oauth2-extension.zip")
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
@router.post("/dashboard/welcome-shown")
async def dashboard_welcome_shown(request: Request):
"""Mark welcome modal as shown for this session"""
if 'session' in request.scope:
request.session['welcome_shown'] = True
return JSONResponse({'success': True})
@router.get("/dashboard/tor/status")
async def dashboard_tor_status(request: Request):
"""Get Tor hidden service status"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
if request.session.get('role') != 'admin':
return JSONResponse({'success': False, 'error': 'Admin access required'}, status_code=403)
try:
from aisbf.app.startup import get_aisbf_config_path
config_path = get_aisbf_config_path()
with open(config_path) as f:
aisbf_config = json.load(f)
tor_cfg = aisbf_config.get('tor', {})
# tor_service lives in main.py app state
from fastapi import Request as _Req
tor_service = None
try:
import main as _main
tor_service = _main._app_state.get('tor_service')
except Exception:
pass
return JSONResponse({
'enabled': bool(tor_cfg.get('enabled', False)),
'running': tor_service is not None and tor_service.is_connected() if tor_service else False,
'onion_address': tor_service.onion_address if tor_service and hasattr(tor_service, 'onion_address') else None
})
except Exception as e:
return JSONResponse({'success': False, 'error': str(e)}, status_code=500)
@router.post("/dashboard/contact")
async def dashboard_contact(request: Request):
"""Handle contact form submissions"""
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
is_authenticated = not require_dashboard_auth(request)
try:
data = await request.json()
message_type = data.get('type')
title = data.get('title')
message = data.get('message')
if not all([message_type, title, message]):
return JSONResponse({'success': False, 'error': 'All fields are required'}, status_code=400)
user_id = request.session.get('user_id')
username = request.session.get('username', 'Unknown')
email = request.session.get('email')
if not is_authenticated:
email = data.get('email', '').strip()
if not email:
return JSONResponse({'success': False, 'error': 'Email is required'}, status_code=400)
username = 'Guest'
user_id = None
smtp_config = _config.aisbf.smtp if _config and _config.aisbf and hasattr(_config.aisbf, 'smtp') else None
if not smtp_config or not smtp_config.enabled:
return JSONResponse({'success': False, 'error': 'Email service is not configured'}, status_code=500)
msg = MIMEMultipart('alternative')
msg['Subject'] = f"[AISBF Contact] {message_type.upper()}: {title}"
msg['From'] = f"{smtp_config.from_name} <{smtp_config.from_email}>"
msg['To'] = "stefy@aisbf.cloud"
if email:
msg['Reply-To'] = email
html = f"<h3>Contact Form</h3><p><b>Type:</b> {message_type}</p><p><b>Title:</b> {title}</p><p><b>User:</b> {username} ({user_id or 'guest'})</p><p><b>Email:</b> {email or 'N/A'}</p><pre>{message}</pre>"
msg.attach(MIMEText(html, 'html'))
if smtp_config.use_ssl:
with smtplib.SMTP_SSL(smtp_config.host, smtp_config.port) as s:
if smtp_config.username:
s.login(smtp_config.username, smtp_config.password)
s.send_message(msg)
else:
with smtplib.SMTP(smtp_config.host, smtp_config.port) as s:
s.ehlo()
if smtp_config.use_tls:
s.starttls(); s.ehlo()
if smtp_config.username:
s.login(smtp_config.username, smtp_config.password)
s.send_message(msg)
return JSONResponse({'success': True})
except Exception as e:
return JSONResponse({'success': False, 'error': str(e)}, status_code=500)
from fastapi import APIRouter, Request, Form, Query, HTTPException, UploadFile, File
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, Response, StreamingResponse, FileResponse
from typing import Optional
import json, logging, os, time, re
from pathlib import Path
from datetime import datetime, timedelta
from aisbf.database import DatabaseRegistry
from aisbf.database import _hash_password as _db_hash_password
from aisbf import __version__
from aisbf.app.templates import url_for, get_base_url
from aisbf.app.startup import _reload_global_config, _apply_condense_defaults_provider, _apply_condense_defaults_rotation, _providers_json_path, _rotations_json_path, _autoselect_json_path, _claude_cli_mode
from aisbf.app.middleware import _is_local_client
from aisbf.app.model_cache import fetch_provider_models
from aisbf.routes.auth import require_dashboard_auth, require_api_auth, require_api_admin, require_admin
import httpx
router = APIRouter()
_config = None
_templates = None
logger = logging.getLogger(__name__)
def init(config, templates):
global _config, _templates
_config = config
_templates = templates
def get_user_auth_files_dir(user_id) -> Path:
auth_files_dir = Path.home() / '.aisbf' / 'user_auth_files' / str(user_id)
auth_files_dir.mkdir(parents=True, exist_ok=True)
return auth_files_dir
def get_admin_auth_files_dir() -> Path:
auth_files_dir = Path.home() / '.aisbf' / 'admin_auth_files'
auth_files_dir.mkdir(parents=True, exist_ok=True)
return auth_files_dir
def _apply_usage_disable(db, user_id, provider_id: str, usage_data: dict):
pass
@router.get("/dashboard", response_class=HTMLResponse)
async def dashboard_index(request: Request):
"""Dashboard overview page"""
# Clear template cache to prevent unhashable dict errors
_templates.env.cache.clear()
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
# Welcome modal and footer links are handled by dashboard_context_middleware
# No need to override them here - request.state already has the correct values
if request.session.get('role') == 'admin':
# Admin dashboard
db = DatabaseRegistry.get_config_database()
users_count = len(db.get_users())
return _templates.TemplateResponse(
request=request,
name="dashboard/index.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"providers_count": len(_config.providers) if _config else 0,
"rotations_count": len(_config.rotations) if _config else 0,
"autoselect_count": len(_config.autoselect) if _config else 0,
"server_config": server_config or {},
"users_count": users_count,
}
)
else:
# User dashboard - show user stats
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
# Get user statistics
usage_stats = {
'total_tokens': 0,
'requests_today': 0
}
if user_id:
# Get token usage for this user
token_usage = db.get_user_token_usage(user_id)
usage_stats['total_tokens'] = sum(row['token_count'] for row in token_usage)
# Count requests today
from datetime import datetime, timedelta
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
usage_stats['requests_today'] = len([
row for row in token_usage
if (datetime.fromisoformat(row['timestamp']) if isinstance(row['timestamp'], str) else row['timestamp']) >= today
])
# Get user config counts
providers_count = len(db.get_user_providers(user_id))
rotations_count = len(db.get_user_rotations(user_id))
autoselects_count = len(db.get_user_autoselects(user_id))
# Get recent activity (last 10)
recent_activity = token_usage[-10:] if token_usage else []
else:
providers_count = 0
rotations_count = 0
autoselects_count = 0
recent_activity = []
# Get subscription info
subscription = db.get_user_subscription(user_id) if user_id else None
current_tier = db.get_user_tier(user_id) if user_id else None
payment_methods = db.get_user_payment_methods(user_id) if user_id else []
all_tiers = db.get_visible_tiers() if user_id else []
# Get currency settings
currency_settings = db.get_currency_settings()
currency_symbol = currency_settings.get('currency_symbol', '$')
# Determine if there are higher tiers available to upgrade to
upgrade_tiers = []
if current_tier:
for t in all_tiers:
if not t.get('is_default') and t['price_monthly'] > current_tier.get('price_monthly', 0):
upgrade_tiers.append(t)
elif all_tiers:
upgrade_tiers = [t for t in all_tiers if not t.get('is_default')]
return _templates.TemplateResponse(
request=request,
name="dashboard/user_index.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"usage_stats": usage_stats,
"providers_count": providers_count,
"rotations_count": rotations_count,
"autoselects_count": autoselects_count,
"recent_activity": recent_activity,
"subscription": subscription,
"current_tier": current_tier,
"payment_methods": payment_methods,
"currency_symbol": currency_symbol,
"upgrade_tiers": upgrade_tiers,
"display_name": (db.get_user_by_id(user_id) or {}).get('display_name') or request.session.get('username', '') if user_id else request.session.get('username', '')
}
)
@router.get("/dashboard/providers", response_class=HTMLResponse)
async def dashboard_providers(request: Request):
"""Edit providers configuration"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
# Check if current user is config admin (from aisbf.json)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
if is_config_admin:
# Config admin: load from JSON files
config_path = Path.home() / '.aisbf' / 'providers.json'
if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'providers.json'
with open(config_path) as f:
full_config = json.load(f)
# Extract just the providers object (handle both nested and flat structures)
if 'providers' in full_config and isinstance(full_config['providers'], dict):
providers_data = full_config['providers']
else:
# Fallback for flat structure (backward compatibility)
providers_data = {k: v for k, v in full_config.items() if k != 'condensation'}
else:
# Database user: load from database
db = DatabaseRegistry.get_config_database()
user_providers = db.get_user_providers(current_user_id)
# Apply stored sort order if any
saved_order = db.get_sort_order(current_user_id, 'provider')
if saved_order:
order_map = {k: i for i, k in enumerate(saved_order)}
user_providers = sorted(user_providers, key=lambda p: order_map.get(p['provider_id'], len(saved_order)))
# Convert datetime objects to strings for JSON serialization
for provider in user_providers:
if 'created_at' in provider and provider['created_at']:
provider['created_at'] = provider['created_at'].isoformat() if hasattr(provider['created_at'], 'isoformat') else str(provider['created_at'])
if 'updated_at' in provider and provider['updated_at']:
provider['updated_at'] = provider['updated_at'].isoformat() if hasattr(provider['updated_at'], 'isoformat') else str(provider['updated_at'])
# Always pass raw user providers format to the template (array)
providers_data = user_providers
# Check for success parameter
success = request.query_params.get('success')
if is_config_admin:
# Config admin: use admin template
return _templates.TemplateResponse(
request=request,
name="dashboard/providers.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"providers_json": json.dumps(providers_data),
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"success": "Configuration saved successfully!" if success else None
}
)
else:
# Database user: use user template with proper context
return _templates.TemplateResponse(
request=request,
name="dashboard/user_providers.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_providers_json": json.dumps(providers_data),
"user_id": current_user_id,
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"success": "Configuration saved successfully!" if success else None
}
)
async def _auto_detect_provider_models(provider_key: str, provider: dict) -> list:
"""
Auto-detect models from a provider's API endpoint.
Tries to fetch models from the provider's /v1/models or /models endpoint.
For Kilo providers, uses OAuth2 authentication if available.
Args:
provider_key: Provider identifier (e.g., 'kilo', 'my-openai-provider')
provider: Provider configuration dict
Returns:
List of model dicts, or empty list if detection fails
"""
import logging
logger = logging.getLogger(__name__)
try:
endpoint = provider.get('endpoint', '')
if not endpoint:
logger.debug(f"No endpoint for provider '{provider_key}', skipping auto-detection")
return []
provider_type = provider.get('type', 'openai')
api_key = provider.get('api_key', '')
# Skip if API key is a placeholder
if api_key and api_key.startswith('YOUR_'):
api_key = ''
# Check if this is a Kilo provider (by type or by endpoint URL)
is_kilo_provider = provider_type in ('kilo', 'kilocode')
if not is_kilo_provider:
# Also check endpoint URL for Kilo domains
kilo_domains = ['kilocode.ai', 'api.kilo.ai', 'kilo.ai']
for domain in kilo_domains:
if domain in endpoint:
is_kilo_provider = True
break
# For Kilo providers, try to get OAuth2 token
if is_kilo_provider:
from aisbf.auth.kilo import KiloOAuth2
kilo_config = provider.get('kilo_config', {})
credentials_file = kilo_config.get('credentials_file', '~/.kilo_credentials.json')
oauth2 = KiloOAuth2(credentials_file=credentials_file, api_base=endpoint)
token = await oauth2.get_valid_token()
if token:
api_key = token
logger.info(f"Using OAuth2 token for Kilo provider '{provider_key}'")
else:
logger.warning(f"No OAuth2 token available for Kilo provider '{provider_key}', please authenticate first")
return []
# Skip if no authentication available
if not api_key:
logger.debug(f"No API key or token for provider '{provider_key}', skipping auto-detection")
return []
# Build models URL - try multiple paths
models_url = None
response_data = None
for path in ['/v1/models', '/models']:
test_url = endpoint.rstrip('/') + path
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as client:
headers = {'Authorization': f'Bearer {api_key}'}
response = await client.get(test_url, headers=headers)
if response.status_code == 200:
models_url = test_url
response_data = response.json()
break
elif response.status_code == 401:
logger.debug(f"Authentication failed for {test_url}")
else:
logger.debug(f"Got status {response.status_code} from {test_url}")
except Exception as e:
logger.debug(f"Error fetching {test_url}: {e}")
continue
if not models_url or not response_data:
logger.debug(f"Could not reach models endpoint for provider '{provider_key}'")
return []
# Parse response - handle both OpenAI format {data: [...]} and array format
models_list = response_data.get('data', response_data) if isinstance(response_data, dict) else response_data
if not isinstance(models_list, list):
logger.debug(f"Unexpected models response format for provider '{provider_key}'")
return []
# Convert to our model format
detected_models = []
for model_data in models_list:
if isinstance(model_data, str):
# Simple string model ID
detected_models.append({
'name': model_data,
'rate_limit': 0,
'max_request_tokens': 100000,
'context_size': 100000
})
elif isinstance(model_data, dict):
# Dict with id/name
model_id = model_data.get('id', model_data.get('model', ''))
if not model_id:
continue
# Extract context size
context_size = (
model_data.get('context_window') or
model_data.get('context_length') or
model_data.get('max_input_tokens')
)
detected_models.append({
'name': model_id,
'rate_limit': 0,
'max_request_tokens': int(context_size) if context_size else 100000,
'context_size': int(context_size) if context_size else 100000
})
logger.info(f"Auto-detected {len(detected_models)} models for provider '{provider_key}' from {models_url}")
return detected_models
except Exception as e:
logger.warning(f"Failed to auto-detect models for provider '{provider_key}': {e}")
return []
@router.post("/dashboard/providers")
async def dashboard_providers_save(request: Request, config: str = Form(...)):
"""Save providers configuration"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
# Check if current user is config admin (from aisbf.json)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
# Validate JSON
providers_data = json.loads(config)
# Apply defaults: if condense_method is set but condense_context is not, default to 80
for provider_key, provider in providers_data.items():
if 'models' in provider and isinstance(provider['models'], list):
for model in provider['models']:
if 'condense_method' in model and model.get('condense_method'):
if 'condense_context' not in model or model.get('condense_context') is None:
model['condense_context'] = 80
if is_config_admin:
# Config admin: save to JSON files
config_path = Path.home() / '.aisbf' / 'providers.json'
if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'providers.json'
# Read existing config to preserve condensation settings
with open(config_path) as f:
full_config = json.load(f)
# Update providers section while preserving other keys
full_config['providers'] = providers_data
# Save to file with full structure
save_path = Path.home() / '.aisbf' / 'providers.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
else:
# Database user: save to database
db = DatabaseRegistry.get_config_database()
# Get existing providers to find which to delete
existing_providers = db.get_user_providers(current_user_id)
existing_provider_keys = {p['provider_id'] for p in existing_providers}
new_provider_keys = set(providers_data.keys())
# Delete providers that are no longer present
providers_to_delete = existing_provider_keys - new_provider_keys
for provider_key in providers_to_delete:
db.delete_user_provider(current_user_id, provider_key)
# Save each provider to database
for provider_key, provider_config in providers_data.items():
db.save_user_provider(current_user_id, provider_key, provider_config)
logger.info(f"Saved {len(providers_data)} provider(s) to database for user {current_user_id}")
if is_config_admin:
success_msg = "Configuration saved successfully!"
return _templates.TemplateResponse(
request=request,
name="dashboard/providers.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"providers_json": json.dumps(providers_data),
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"success": success_msg
}
)
else:
success_msg = "Configuration saved successfully!"
return _templates.TemplateResponse(
request=request,
name="dashboard/user_providers.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_providers_json": json.dumps(providers_data),
"user_id": current_user_id,
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"success": success_msg
}
)
except json.JSONDecodeError as e:
# Reload current config on error
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
if is_config_admin:
config_path = Path.home() / '.aisbf' / 'providers.json'
if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'providers.json'
with open(config_path) as f:
full_config = json.load(f)
# Extract providers
if 'providers' in full_config and isinstance(full_config['providers'], dict):
providers_data = full_config['providers']
else:
providers_data = {k: v for k, v in full_config.items() if k != 'condensation'}
return _templates.TemplateResponse(
request=request,
name="dashboard/providers.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"providers_json": json.dumps(providers_data),
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"error": f"Invalid JSON: {str(e)}"
}
)
else:
db = DatabaseRegistry.get_config_database()
user_providers = db.get_user_providers(current_user_id)
return _templates.TemplateResponse(
request=request,
name="dashboard/user_providers.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_providers_json": json.dumps(user_providers),
"user_id": current_user_id,
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"error": f"Invalid JSON: {str(e)}"
}
)
@router.post("/dashboard/providers/get-models")
async def dashboard_providers_get_models(request: Request):
"""Fetch models from provider API"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
# Parse request body
body = await request.json()
provider_key = body.get('provider_key')
if not provider_key:
return JSONResponse({
"success": False,
"error": "provider_key is required"
}, status_code=400)
# Get user ID from session
current_user_id = request.session.get('user_id')
# Get provider handler - pass user_id to automatically handle user-specific providers
from aisbf.providers import get_provider_handler
try:
handler = get_provider_handler(provider_key, user_id=current_user_id)
except ValueError as e:
return JSONResponse({
"success": False,
"error": str(e)
}, status_code=404)
# Fetch models from provider
models_result = await handler.get_models()
# Handle pending authorization status
if isinstance(models_result, dict) and models_result.get("status") == "pending_authorization":
return JSONResponse({
"success": False,
"authorization_required": True,
"authorization_url": models_result.get("verification_url"),
"device_code": models_result.get("code"),
"expires_in": models_result.get("expires_in"),
"poll_interval": models_result.get("poll_interval"),
"message": f"Please visit {models_result.get('verification_url')} and enter code: {models_result.get('code')}"
}, status_code=401)
models = models_result
# Convert Model objects to dicts with all available fields
models_data = []
for model in models:
model_dict = {
"id": model.id,
"name": model.name,
"provider_id": model.provider_id
}
# Add all optional fields if present
optional_fields = [
'weight', 'rate_limit', 'max_request_tokens',
'rate_limit_TPM', 'rate_limit_TPH', 'rate_limit_TPD',
'context_size', 'context_length', 'condense_context', 'condense_method',
'error_cooldown', 'description', 'architecture', 'pricing',
'top_provider', 'supported_parameters', 'default_parameters'
]
for field in optional_fields:
if hasattr(model, field):
value = getattr(model, field)
if value is not None:
model_dict[field] = value
models_data.append(model_dict)
return JSONResponse({
"success": True,
"models": models_data
})
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error fetching models for provider: {str(e)}", exc_info=True)
return JSONResponse({
"success": False,
"error": str(e)
}, status_code=500)
@router.get("/dashboard/providers/{provider_id}/configured-models")
async def get_provider_configured_models(request: Request, provider_id: str, search: str = ""):
"""Return model names from a provider's local config (no external API calls)"""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"models": []}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
provider = None
if is_config_admin:
config_path = Path.home() / '.aisbf' / 'providers.json'
if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'providers.json'
with open(config_path) as f:
full_config = json.load(f)
if 'providers' in full_config and isinstance(full_config['providers'], dict):
providers_data = full_config['providers']
else:
providers_data = {k: v for k, v in full_config.items() if k != 'condensation'}
provider = providers_data.get(provider_id)
else:
db = DatabaseRegistry.get_config_database()
user_providers = db.get_user_providers(current_user_id)
match = next((p for p in user_providers if p['provider_id'] == provider_id), None)
if match:
provider = match.get('config', match)
if not provider:
return JSONResponse({"models": []})
models = provider.get('models', [])
model_names = [m.get('name', '') if isinstance(m, dict) else str(m) for m in models]
model_names = [n for n in model_names if n]
if search:
search_lower = search.lower()
model_names = [n for n in model_names if search_lower in n.lower()]
return JSONResponse({"models": model_names[:50]})
@router.get("/dashboard/providers/{provider_id}/search-models")
async def search_provider_models_api(request: Request, provider_id: str, query: str = "", refresh: bool = False):
"""Search provider models; fetches from live API if local config has none or refresh=True."""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"models": [], "error": "unauthorized"}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
models = []
if is_config_admin:
try:
config_path = Path.home() / '.aisbf' / 'providers.json'
if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'providers.json'
with open(config_path) as f:
full_config = json.load(f)
if 'providers' in full_config and isinstance(full_config['providers'], dict):
providers_data = full_config['providers']
else:
providers_data = {k: v for k, v in full_config.items() if k != 'condensation'}
provider = providers_data.get(provider_id, {})
raw = provider.get('models', [])
models = [m.get('name', '') if isinstance(m, dict) else str(m) for m in raw]
models = [n for n in models if n]
except Exception:
pass
else:
try:
db = DatabaseRegistry.get_config_database()
user_providers = db.get_user_providers(current_user_id)
match = next((p for p in user_providers if p['provider_id'] == provider_id), None)
if match:
prov = match.get('config', match)
raw = prov.get('models', [])
models = [m.get('name', '') if isinstance(m, dict) else str(m) for m in raw]
models = [n for n in models if n]
except Exception:
pass
fetched_live = False
if not models or refresh:
try:
live = await fetch_provider_models(provider_id, user_id=current_user_id)
if live:
models = [m.get('name', m.get('id', '')) if isinstance(m, dict) else str(m) for m in live]
models = [n for n in models if n]
fetched_live = True
except Exception:
pass
if query:
q = query.lower()
models = [m for m in models if q in m.lower()]
return JSONResponse({"models": models[:200], "fetched_live": fetched_live})
@router.get("/dashboard/search-all-models")
async def search_all_models_api(request: Request, query: str = "", refresh: bool = False):
"""Return all available models (rotations + provider models) for autoselect, with optional live refresh."""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"models": [], "error": "unauthorized"}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
all_models = []
if is_config_admin:
if _config:
for rid in _config.rotations:
all_models.append({'id': rid, 'name': f'{rid} (rotation)', 'type': 'rotation'})
try:
providers_path = Path.home() / '.aisbf' / 'providers.json'
if not providers_path.exists():
providers_path = Path(__file__).parent / 'config' / 'providers.json'
with open(providers_path) as f:
pc = json.load(f)
pd_map = pc.get('providers', {k: v for k, v in pc.items() if k != 'condensation'})
for pid, prov in pd_map.items():
pmodels = prov.get('models', [])
if not pmodels and refresh:
try:
live = await fetch_provider_models(pid)
if live:
pmodels = live
except Exception:
pass
for m in pmodels:
mname = m.get('name', m.get('id', '')) if isinstance(m, dict) else str(m)
if mname:
mid = f"{pid}/{mname}"
all_models.append({'id': mid, 'name': f"{mid} (provider model)", 'type': 'provider'})
except Exception:
pass
else:
try:
db = DatabaseRegistry.get_config_database()
for rot in db.get_user_rotations(current_user_id):
rid = rot['rotation_id']
all_models.append({'id': rid, 'name': f'{rid} (rotation)', 'type': 'rotation'})
for prov in db.get_user_providers(current_user_id):
pid = prov['provider_id']
pconfig = prov.get('config', prov)
pmodels = pconfig.get('models', [])
if not pmodels and refresh:
try:
live = await fetch_provider_models(pid, user_id=current_user_id)
if live:
pmodels = live
except Exception:
pass
for m in pmodels:
mname = m.get('name', m.get('id', '')) if isinstance(m, dict) else str(m)
if mname:
mid = f"{pid}/{mname}"
all_models.append({'id': mid, 'name': f"{mid} (provider model)", 'type': 'provider'})
except Exception:
pass
if query:
q = query.lower()
all_models = [m for m in all_models if q in m['id'].lower() or q in m['name'].lower()]
return JSONResponse({"models": all_models[:300]})
@router.get("/dashboard/rotations", response_class=HTMLResponse)
async def dashboard_rotations(request: Request):
"""Edit rotations configuration"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
# Check if current user is config admin (from aisbf.json)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
if is_config_admin:
# Config admin: load from JSON files
config_path = Path.home() / '.aisbf' / 'rotations.json'
if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'rotations.json'
with open(config_path) as f:
rotations_data = json.load(f)
else:
# Database user: load from database
db = DatabaseRegistry.get_config_database()
user_rotations = db.get_user_rotations(current_user_id)
# Apply stored sort order if any
saved_order = db.get_sort_order(current_user_id, 'rotation')
if saved_order:
order_map = {k: i for i, k in enumerate(saved_order)}
user_rotations = sorted(user_rotations, key=lambda r: order_map.get(r['rotation_id'], len(saved_order)))
# Convert to the format expected by the frontend
rotations_data = {"rotations": {}, "notifyerrors": False}
for rotation in user_rotations:
rotations_data["rotations"][rotation['rotation_id']] = rotation['config']
# Get available providers - user-specific for database users
if is_config_admin:
# Admin: use global providers
available_providers = list(_config.providers.keys()) if _config else []
providers_meta = {k: {"type": getattr(v, 'type', 'openai')} for k, v in (_config.providers.items() if _config else {}.items())}
else:
# Database user: use ONLY their own providers
db = DatabaseRegistry.get_config_database()
user_providers = db.get_user_providers(current_user_id)
available_providers = [p['provider_id'] for p in user_providers]
providers_meta = {p['provider_id']: {"type": p['config'].get('type', 'openai')} for p in user_providers}
# Check for success parameter
success = request.query_params.get('success')
if is_config_admin:
# Config admin: use admin template
return _templates.TemplateResponse(
request=request,
name="dashboard/rotations.html",
context={
"request": request,
"session": request.session,
"rotations_json": json.dumps(rotations_data),
"available_providers": json.dumps(available_providers),
"providers_meta": json.dumps(providers_meta),
"success": "Configuration saved successfully!" if success else None
}
)
else:
# Database user: use user template
return _templates.TemplateResponse(
request=request,
name="dashboard/user_rotations.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"rotations_json": json.dumps(rotations_data),
"available_providers": json.dumps(available_providers),
"providers_meta": json.dumps(providers_meta),
"success": "Configuration saved successfully!" if success else None
}
)
@router.post("/dashboard/rotations")
async def dashboard_rotations_save(request: Request, config: str = Form(...)):
"""Save rotations configuration"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
# Check if current user is config admin (from aisbf.json)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
rotations_data = json.loads(config)
# Apply defaults: if condense_method is set but condense_context is not, default to 80
if 'rotations' in rotations_data:
for rotation_key, rotation in rotations_data['rotations'].items():
if 'providers' in rotation and isinstance(rotation['providers'], list):
for provider in rotation['providers']:
if 'models' in provider and isinstance(provider['models'], list):
for model in provider['models']:
if 'condense_method' in model and model.get('condense_method'):
if 'condense_context' not in model or model.get('condense_context') is None:
model['condense_context'] = 80
if is_config_admin:
# Config admin: save to JSON files
config_path = Path.home() / '.aisbf' / 'rotations.json'
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f:
json.dump(rotations_data, f, indent=2)
_reload_global_config()
else:
# Database user: save to database
db = DatabaseRegistry.get_config_database()
rotations = rotations_data.get('rotations', {})
# Delete rotations that are no longer present
existing_rotations = db.get_user_rotations(current_user_id)
existing_rotation_keys = {r['rotation_id'] for r in existing_rotations}
new_rotation_keys = set(rotations.keys())
for rotation_key in existing_rotation_keys - new_rotation_keys:
db.delete_user_rotation(current_user_id, rotation_key)
# Save each rotation to database
for rotation_key, rotation_config in rotations.items():
db.save_user_rotation(current_user_id, rotation_key, rotation_config)
logger.info(f"Saved {len(rotations)} rotation(s) to database for user {current_user_id}")
if is_config_admin:
# Get global config safely
from aisbf.config import config as global_config
available_providers = list(global_config.providers.keys()) if global_config else []
return _templates.TemplateResponse(
request=request,
name="dashboard/rotations.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"rotations_json": json.dumps(rotations_data),
"available_providers": json.dumps(available_providers),
"success": "Configuration saved successfully!"
}
)
else:
db = DatabaseRegistry.get_config_database()
user_rotations = db.get_user_rotations(current_user_id)
# For database users, get their own providers
user_providers = db.get_user_providers(current_user_id)
available_providers = [p['provider_id'] for p in user_providers]
return _templates.TemplateResponse(
request=request,
name="dashboard/user_rotations.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_rotations_json": json.dumps(rotations_data),
"available_providers": json.dumps(available_providers),
"user_id": current_user_id,
"success": "Configuration saved successfully!"
}
)
except json.JSONDecodeError as e:
# Reload current config on error
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
if is_config_admin:
config_path = Path.home() / '.aisbf' / 'rotations.json'
if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'rotations.json'
with open(config_path) as f:
rotations_data = json.load(f)
else:
db = DatabaseRegistry.get_config_database()
user_rotations = db.get_user_rotations(current_user_id)
rotations_data = {"rotations": {}, "notifyerrors": False}
for rotation in user_rotations:
rotations_data["rotations"][rotation['rotation_id']] = rotation['config']
if is_config_admin:
available_providers = list(_config.providers.keys()) if _config else []
return _templates.TemplateResponse(
request=request,
name="dashboard/rotations.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"rotations_json": json.dumps(rotations_data),
"available_providers": json.dumps(available_providers),
"error": f"Invalid JSON: {str(e)}"
}
)
else:
db = DatabaseRegistry.get_config_database()
user_providers = db.get_user_providers(current_user_id)
available_providers = [p['provider_id'] for p in user_providers]
return _templates.TemplateResponse(
request=request,
name="dashboard/user_rotations.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_rotations_json": json.dumps(rotations_data),
"available_providers": json.dumps(available_providers),
"user_id": current_user_id,
"error": f"Invalid JSON: {str(e)}"
}
)
@router.get("/dashboard/autoselect", response_class=HTMLResponse)
async def dashboard_autoselect(request: Request):
"""Edit autoselect configuration"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
# Check if current user is config admin (from aisbf.json)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
if is_config_admin:
# Config admin: load from JSON files
config_path = Path.home() / '.aisbf' / 'autoselect.json'
if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'autoselect.json'
with open(config_path) as f:
autoselect_data = json.load(f)
else:
# Database user: load from database
db = DatabaseRegistry.get_config_database()
user_autoselects = db.get_user_autoselects(current_user_id)
# Apply stored sort order if any
saved_order = db.get_sort_order(current_user_id, 'autoselect')
if saved_order:
order_map = {k: i for i, k in enumerate(saved_order)}
user_autoselects = sorted(user_autoselects, key=lambda a: order_map.get(a['autoselect_id'], len(saved_order)))
# Convert to the format expected by the frontend
autoselect_data = {}
for autoselect in user_autoselects:
autoselect_data[autoselect['autoselect_id']] = autoselect['config']
# Check for success parameter
success = request.query_params.get('success')
if is_config_admin:
# Admin: use global rotations and providers
available_rotations = list(_config.rotations.keys()) if _config else []
available_models = []
# Add global rotation IDs
for rotation_id in available_rotations:
available_models.append({
'id': rotation_id,
'name': f'{rotation_id} (rotation)',
'type': 'rotation'
})
# Add global provider models
providers_path = Path.home() / '.aisbf' / 'providers.json'
if not providers_path.exists():
providers_path = Path(__file__).parent / 'config' / 'providers.json'
admin_providers_meta = {}
if providers_path.exists():
with open(providers_path) as f:
providers_config = json.load(f)
providers_data = providers_config.get('providers', {})
for provider_id, provider in providers_data.items():
admin_providers_meta[provider_id] = {"type": provider.get('type', 'openai')}
if 'models' in provider and isinstance(provider['models'], list):
for model in provider['models']:
model_id = f"{provider_id}/{model['name']}"
available_models.append({
'id': model_id,
'name': f"{model_id} (provider model)",
'type': 'provider'
})
# Config admin: use admin template
return _templates.TemplateResponse(
request=request,
name="dashboard/autoselect.html",
context={
"request": request,
"session": request.session,
"autoselect_json": json.dumps(autoselect_data),
"available_rotations": json.dumps(available_rotations),
"available_models": json.dumps(available_models),
"providers_meta": json.dumps(admin_providers_meta),
"success": "Configuration saved successfully!" if success else None
}
)
else:
# Database user: use ONLY their own rotations and providers
db = DatabaseRegistry.get_config_database()
user_autoselects = db.get_user_autoselects(current_user_id)
# Get only user's own rotations
user_rotations = db.get_user_rotations(current_user_id)
available_rotations = [rot['rotation_id'] for rot in user_rotations]
# Get only user's own providers
user_providers = db.get_user_providers(current_user_id)
available_models = []
user_providers_meta = {p['provider_id']: {"type": p['config'].get('type', 'openai')} for p in user_providers}
# Add user rotation IDs
for rotation_id in available_rotations:
available_models.append({
'id': rotation_id,
'name': f'{rotation_id} (rotation)',
'type': 'rotation'
})
# Add user provider models
for provider in user_providers:
provider_config = provider['config']
if 'models' in provider_config and isinstance(provider_config['models'], list):
for model in provider_config['models']:
model_id = f"{provider['provider_id']}/{model['name']}"
available_models.append({
'id': model_id,
'name': f"{model_id} (provider model)",
'type': 'provider'
})
# Database user: use user template
return _templates.TemplateResponse(
request=request,
name="dashboard/user_autoselects.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"autoselect_json": json.dumps(autoselect_data),
"available_rotations": json.dumps(available_rotations),
"available_models": json.dumps(available_models),
"providers_meta": json.dumps(user_providers_meta),
"user_id": current_user_id,
"success": "Configuration saved successfully!" if success else None
}
)
@router.post("/dashboard/autoselect")
async def dashboard_autoselect_save(request: Request, config: str = Form(...)):
"""Save autoselect configuration"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
# Check if current user is config admin (from aisbf.json)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
autoselect_data = json.loads(config)
# Sanitize every autoselect entry before saving
for key, cfg in autoselect_data.items():
# Strip available_models with empty model_id
original = cfg.get('available_models', [])
valid = [m for m in original if (m.get('model_id') or '').strip()]
if len(valid) != len(original):
logger.warning(f"dashboard_autoselect_save: stripped {len(original) - len(valid)} empty model_id entry(s) from '{key}'")
cfg['available_models'] = valid
# Default selection_model to "internal" when blank
if not (cfg.get('selection_model') or '').strip():
cfg['selection_model'] = 'internal'
if is_config_admin:
# Config admin: save to JSON files
config_path = Path.home() / '.aisbf' / 'autoselect.json'
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f:
json.dump(autoselect_data, f, indent=2)
_reload_global_config()
else:
# Database user: save to database
db = DatabaseRegistry.get_config_database()
# Delete autoselects that are no longer present
existing_autoselects = db.get_user_autoselects(current_user_id)
existing_autoselect_keys = {a['autoselect_id'] for a in existing_autoselects}
new_autoselect_keys = set(autoselect_data.keys())
for autoselect_key in existing_autoselect_keys - new_autoselect_keys:
db.delete_user_autoselect(current_user_id, autoselect_key)
# Save each autoselect to database
for autoselect_key, autoselect_config in autoselect_data.items():
db.save_user_autoselect(current_user_id, autoselect_key, autoselect_config)
logger.info(f"Saved {len(autoselect_data)} autoselect(s) to database for user {current_user_id}")
if is_config_admin:
# Get global config safely
from aisbf.config import config as global_config
available_rotations = list(global_config.rotations.keys()) if global_config else []
# Get available provider models
available_models = []
# Add rotation IDs
for rotation_id in available_rotations:
available_models.append({
'id': rotation_id,
'name': f'{rotation_id} (rotation)',
'type': 'rotation'
})
# Add provider models
providers_path = Path.home() / '.aisbf' / 'providers.json'
if not providers_path.exists():
providers_path = Path(__file__).parent / 'config' / 'providers.json'
if providers_path.exists():
with open(providers_path) as f:
providers_config = json.load(f)
providers_data = providers_config.get('providers', {})
for provider_id, provider in providers_data.items():
if 'models' in provider and isinstance(provider['models'], list):
for model in provider['models']:
model_id = f"{provider_id}/{model['name']}"
available_models.append({
'id': model_id,
'name': f"{model_id} (provider model)",
'type': 'provider'
})
return _templates.TemplateResponse(
request=request,
name="dashboard/autoselect.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"autoselect_json": json.dumps(autoselect_data),
"available_rotations": json.dumps(available_rotations),
"available_models": json.dumps(available_models),
"success": "Configuration saved successfully!"
}
)
else:
db = DatabaseRegistry.get_config_database()
user_autoselects = db.get_user_autoselects(current_user_id)
# For database users, get available user rotations
user_rotations = db.get_user_rotations(current_user_id)
available_rotations = [rot['rotation_id'] for rot in user_rotations]
# For database users, get available user providers
user_providers = db.get_user_providers(current_user_id)
available_models = []
# Add user rotation IDs
for rotation_id in available_rotations:
available_models.append({
'id': rotation_id,
'name': f'{rotation_id} (rotation)',
'type': 'rotation'
})
# Add user provider models
for provider in user_providers:
provider_config = provider['config']
if 'models' in provider_config and isinstance(provider_config['models'], list):
for model in provider_config['models']:
model_id = f"{provider['provider_id']}/{model['name']}"
available_models.append({
'id': model_id,
'name': f"{model_id} (provider model)",
'type': 'provider'
})
return _templates.TemplateResponse(
request=request,
name="dashboard/user_autoselects.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_autoselects_json": json.dumps(autoselect_data),
"available_rotations": json.dumps(available_rotations),
"available_models": json.dumps(available_models),
"user_id": current_user_id,
"success": "Configuration saved successfully!"
}
)
except json.JSONDecodeError as e:
# Reload current config on error
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
if is_config_admin:
config_path = Path.home() / '.aisbf' / 'autoselect.json'
if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'autoselect.json'
with open(config_path) as f:
autoselect_data = json.load(f)
else:
db = DatabaseRegistry.get_config_database()
user_autoselects = db.get_user_autoselects(current_user_id)
autoselect_data = {}
for autoselect in user_autoselects:
autoselect_data[autoselect['autoselect_id']] = autoselect['config']
if is_config_admin:
available_rotations = list(_config.rotations.keys()) if _config else []
# Get available provider models
available_models = []
# Add rotation IDs
for rotation_id in available_rotations:
available_models.append({
'id': rotation_id,
'name': f'{rotation_id} (rotation)',
'type': 'rotation'
})
# Add provider models
providers_path = Path.home() / '.aisbf' / 'providers.json'
if not providers_path.exists():
providers_path = Path(__file__).parent / 'config' / 'providers.json'
if providers_path.exists():
with open(providers_path) as f:
providers_config = json.load(f)
providers_data = providers_config.get('providers', {})
for provider_id, provider in providers_data.items():
if 'models' in provider and isinstance(provider['models'], list):
for model in provider['models']:
model_id = f"{provider_id}/{model['name']}"
available_models.append({
'id': model_id,
'name': f"{model_id} (provider model)",
'type': 'provider'
})
return _templates.TemplateResponse(
request=request,
name="dashboard/autoselect.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"autoselect_json": json.dumps(autoselect_data),
"available_rotations": json.dumps(available_rotations),
"available_models": json.dumps(available_models),
"error": f"Invalid JSON: {str(e)}"
}
)
else:
db = DatabaseRegistry.get_config_database()
# For database users, get available user rotations
user_rotations = db.get_user_rotations(current_user_id)
available_rotations = [rot['rotation_id'] for rot in user_rotations]
# For database users, get available user providers
user_providers = db.get_user_providers(current_user_id)
available_models = []
# Add user rotation IDs
for rotation_id in available_rotations:
available_models.append({
'id': rotation_id,
'name': f'{rotation_id} (rotation)',
'type': 'rotation'
})
# Add user provider models
for provider in user_providers:
provider_config = provider['config']
if 'models' in provider_config and isinstance(provider_config['models'], list):
for model in provider_config['models']:
model_id = f"{provider['provider_id']}/{model['name']}"
available_models.append({
'id': model_id,
'name': f"{model_id} (provider model)",
'type': 'provider'
})
return _templates.TemplateResponse(
request=request,
name="dashboard/user_autoselects.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_autoselects_json": json.dumps(autoselect_data),
"available_rotations": json.dumps(available_rotations),
"available_models": json.dumps(available_models),
"user_id": current_user_id,
"error": f"Invalid JSON: {str(e)}"
}
)
# ---------------------------------------------------------------------------
# Granular CRUD endpoints — act on a single provider/rotation/autoselect
# and trigger hot-reload of the in-memory config so no restart is needed.
# ---------------------------------------------------------------------------
@router.post("/dashboard/api/provider")
async def api_provider_save(request: Request):
"""Create or update a single provider"""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
body = await request.json()
provider_id = body.get('provider_id')
provider_config = body.get('config', {})
if not provider_id:
return JSONResponse({"success": False, "error": "provider_id required"}, status_code=400)
_apply_condense_defaults_provider(provider_config)
if is_config_admin:
config_path = _providers_json_path()
with open(config_path) as f:
full_config = json.load(f)
if 'providers' not in full_config or not isinstance(full_config['providers'], dict):
full_config['providers'] = {}
full_config['providers'][provider_id] = provider_config
save_path = Path.home() / '.aisbf' / 'providers.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
else:
db = DatabaseRegistry.get_config_database()
db.save_user_provider(current_user_id, provider_id, provider_config)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"api_provider_save error: {e}", exc_info=True)
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@router.delete("/dashboard/api/provider/{provider_id:path}")
async def api_provider_delete(request: Request, provider_id: str):
"""Delete a single provider"""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
if is_config_admin:
config_path = _providers_json_path()
with open(config_path) as f:
full_config = json.load(f)
providers = full_config.get('providers', full_config)
providers.pop(provider_id, None)
save_path = Path.home() / '.aisbf' / 'providers.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
else:
db = DatabaseRegistry.get_config_database()
db.delete_user_provider(current_user_id, provider_id)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"api_provider_delete error: {e}", exc_info=True)
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@router.get("/dashboard/api/provider/{provider_id:path}/usage")
async def api_provider_usage(request: Request, provider_id: str):
"""Return cached usage data for a provider, refreshing from source if stale (>5 min)."""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
current_user_id = request.session.get('user_id')
db = DatabaseRegistry.get_config_database()
STALE_SECONDS = 300 # 5 minutes
# Check DB cache
cached = db.get_provider_usage(current_user_id, provider_id)
now = __import__('datetime').datetime.utcnow()
def _age_seconds(last_updated):
if last_updated is None:
return float('inf')
if hasattr(last_updated, 'utcoffset'):
import datetime as _dt
last_updated = last_updated.replace(tzinfo=None)
if isinstance(last_updated, str):
import datetime as _dt
try:
last_updated = _dt.datetime.fromisoformat(last_updated)
except Exception:
return float('inf')
return (now - last_updated).total_seconds()
if cached and _age_seconds(cached.get('last_updated')) < STALE_SECONDS:
return JSONResponse({"success": True, "supported": True, "usage": cached['usage_data']})
# Fetch fresh data
try:
from aisbf.providers import get_provider_handler
handler = get_provider_handler(provider_id, user_id=current_user_id)
if not handler.supports_usage():
return JSONResponse({"success": True, "supported": False})
usage_data = await handler.get_usage()
if usage_data is None:
if cached:
return JSONResponse({"success": True, "supported": True, "usage": cached['usage_data'], "stale": True})
return JSONResponse({"success": True, "supported": True, "usage": None})
db.save_provider_usage(current_user_id, provider_id, usage_data)
_apply_usage_disable(db, current_user_id, provider_id, usage_data)
return JSONResponse({"success": True, "supported": True, "usage": usage_data})
except Exception as e:
logger.warning(f"api_provider_usage error for {provider_id}: {e}")
if cached:
return JSONResponse({"success": True, "supported": True, "usage": cached['usage_data'], "stale": True})
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@router.post("/dashboard/api/rotation")
async def api_rotation_save(request: Request):
"""Create or update a single rotation"""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
body = await request.json()
rotation_id = body.get('rotation_id')
rotation_config = body.get('config', {})
if not rotation_id:
return JSONResponse({"success": False, "error": "rotation_id required"}, status_code=400)
_apply_condense_defaults_rotation(rotation_config)
if is_config_admin:
config_path = _rotations_json_path()
with open(config_path) as f:
full_config = json.load(f)
if 'rotations' not in full_config or not isinstance(full_config['rotations'], dict):
full_config['rotations'] = {}
full_config['rotations'][rotation_id] = rotation_config
save_path = Path.home() / '.aisbf' / 'rotations.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
else:
db = DatabaseRegistry.get_config_database()
db.save_user_rotation(current_user_id, rotation_id, rotation_config)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"api_rotation_save error: {e}", exc_info=True)
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@router.delete("/dashboard/api/rotation/{rotation_id:path}")
async def api_rotation_delete(request: Request, rotation_id: str):
"""Delete a single rotation"""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
if is_config_admin:
config_path = _rotations_json_path()
with open(config_path) as f:
full_config = json.load(f)
full_config.get('rotations', {}).pop(rotation_id, None)
save_path = Path.home() / '.aisbf' / 'rotations.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
else:
db = DatabaseRegistry.get_config_database()
db.delete_user_rotation(current_user_id, rotation_id)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"api_rotation_delete error: {e}", exc_info=True)
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@router.post("/dashboard/api/autoselect")
async def api_autoselect_save(request: Request):
"""Create or update a single autoselect entry"""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
body = await request.json()
autoselect_id = body.get('autoselect_id')
autoselect_config = body.get('config', {})
if not autoselect_id:
return JSONResponse({"success": False, "error": "autoselect_id required"}, status_code=400)
# Reject entries with empty model_id
available_models = autoselect_config.get('available_models', [])
invalid = [m for m in available_models if not (m.get('model_id') or '').strip()]
if invalid:
return JSONResponse(
{"success": False, "error": f"{len(invalid)} model(s) have an empty model_id — please select a model for each entry before saving."},
status_code=400
)
# Default selection_model to "internal" when blank
if not (autoselect_config.get('selection_model') or '').strip():
autoselect_config['selection_model'] = 'internal'
if is_config_admin:
config_path = _autoselect_json_path()
save_path = Path.home() / '.aisbf' / 'autoselect.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
if config_path.exists():
with open(config_path) as f:
full_config = json.load(f)
else:
full_config = {}
full_config[autoselect_id] = autoselect_config
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
else:
db = DatabaseRegistry.get_config_database()
db.save_user_autoselect(current_user_id, autoselect_id, autoselect_config)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"api_autoselect_save error: {e}", exc_info=True)
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@router.delete("/dashboard/api/autoselect/{autoselect_id:path}")
async def api_autoselect_delete(request: Request, autoselect_id: str):
"""Delete a single autoselect entry"""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
if is_config_admin:
config_path = _autoselect_json_path()
save_path = Path.home() / '.aisbf' / 'autoselect.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
if config_path.exists():
with open(config_path) as f:
full_config = json.load(f)
else:
full_config = {}
full_config.pop(autoselect_id, None)
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
else:
db = DatabaseRegistry.get_config_database()
db.delete_user_autoselect(current_user_id, autoselect_id)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"api_autoselect_delete error: {e}", exc_info=True)
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
def _reorder_dict(d: dict, order: list) -> dict:
"""Return a new dict with keys in the given order (unknown keys appended at end)."""
result = {k: d[k] for k in order if k in d}
for k, v in d.items():
if k not in result:
result[k] = v
return result
@router.get("/dashboard/analytics", response_class=HTMLResponse)
async def dashboard_analytics(
request: Request,
time_range: str = Query("24h"),
from_date: Optional[str] = Query(None),
to_date: Optional[str] = Query(None),
provider_filter: Optional[str] = Query(None),
model_filter: Optional[str] = Query(None),
rotation_filter: Optional[str] = Query(None),
autoselect_filter: Optional[str] = Query(None),
user_filter: Optional[str] = Query(None),
global_only: Optional[str] = Query(None)
):
"""Token usage analytics dashboard"""
from decimal import Decimal
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
from aisbf.analytics import get_analytics
db = DatabaseRegistry.get_config_database()
analytics = get_analytics(db)
from_datetime = None
to_datetime = None
if from_date:
try:
from_datetime = datetime.fromisoformat(from_date.replace('Z', '+00:00'))
except ValueError:
pass
if to_date:
try:
to_datetime = datetime.fromisoformat(to_date.replace('Z', '+00:00'))
except ValueError:
pass
if time_range == 'yesterday':
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 not from_datetime or not to_datetime:
time_range = '24h'
if from_datetime and to_datetime and time_range not in ['yesterday']:
time_range = 'custom'
is_admin = request.session.get('role') == 'admin'
current_user_id = request.session.get('user_id')
user_filter_int = None
if user_filter:
try:
user_filter_int = int(user_filter)
except (ValueError, TypeError):
pass
if global_only == '1':
user_filter_int = -1
if not is_admin and current_user_id is not None:
user_filter_int = current_user_id
raw_users = db.get_users() if db and is_admin else []
all_users = [
{k: (v.isoformat() if isinstance(v, datetime) else v) for k, v in u.items()}
for u in raw_users
]
available_providers = list(_config.providers.keys()) if _config else []
available_rotations = list(_config.rotations.keys()) if _config else []
available_autoselects = list(_config.autoselect.keys()) if _config else []
available_models = []
if _config and hasattr(_config, 'providers'):
for provider_id, provider_config in _config.providers.items():
if hasattr(provider_config, 'models') and provider_config.models:
for model in provider_config.models:
available_models.append(f"{provider_id}/{model.name}")
effective_provider_filter = provider_filter
effective_model_filter = model_filter
if model_filter and '/' in model_filter:
_p, _m = model_filter.split('/', 1)
effective_provider_filter = effective_provider_filter or _p
effective_model_filter = _m
provider_stats = analytics.get_all_providers_stats(
from_datetime, to_datetime, user_filter=user_filter_int,
provider_filter=effective_provider_filter, model_filter=effective_model_filter,
rotation_filter=rotation_filter, autoselect_filter=autoselect_filter
)
token_over_time = analytics.get_token_usage_over_time(
provider_id=effective_provider_filter, time_range=time_range,
from_datetime=from_datetime, to_datetime=to_datetime,
user_filter=user_filter_int, model_filter=effective_model_filter,
rotation_filter=rotation_filter, autoselect_filter=autoselect_filter
)
model_performance = analytics.get_model_performance(
provider_filter=effective_provider_filter, model_filter=effective_model_filter,
rotation_filter=rotation_filter, autoselect_filter=autoselect_filter,
user_filter=user_filter_int, from_datetime=from_datetime, to_datetime=to_datetime
)
cost_overview = analytics.get_cost_overview(
from_datetime, to_datetime, user_filter=user_filter_int,
provider_filter=effective_provider_filter, model_filter=effective_model_filter,
rotation_filter=rotation_filter, autoselect_filter=autoselect_filter
)
date_range_usage = None
if from_datetime or to_datetime:
start = from_datetime or (datetime.now() - timedelta(days=1))
end = to_datetime or datetime.now()
date_range_usage = analytics.get_token_usage_by_date_range(
effective_provider_filter, start, end, user_filter=user_filter_int)
rotation_breakdown = analytics.get_rotation_breakdown(
from_datetime, to_datetime, user_filter=user_filter_int, rotation_filter=rotation_filter)
autoselect_breakdown = analytics.get_autoselect_breakdown(
from_datetime, to_datetime, user_filter=user_filter_int, autoselect_filter=autoselect_filter)
is_config_admin = is_admin and current_user_id is None
def decimal_default(obj):
if isinstance(obj, Decimal):
return int(obj)
raise TypeError(f"Object of type {obj.__class__.__name__} is not JSON serializable")
return _templates.TemplateResponse(
request=request,
name="dashboard/analytics.html",
context={
"request": request,
"session": request.session,
"is_admin": is_admin,
"is_config_admin": is_config_admin,
"provider_stats": provider_stats,
"token_over_time": json.dumps(token_over_time, default=decimal_default),
"model_performance": model_performance,
"cost_overview": cost_overview,
"recommendations": [],
"optimization_savings": 0,
"selected_time_range": time_range,
"from_date": from_date,
"to_date": to_date,
"date_range_usage": date_range_usage,
"available_providers": available_providers,
"available_models": available_models,
"available_rotations": available_rotations,
"available_autoselects": available_autoselects,
"available_users": all_users,
"selected_provider": provider_filter,
"selected_model": model_filter,
"selected_rotation": rotation_filter,
"selected_autoselect": autoselect_filter,
"selected_user": user_filter,
"global_only": global_only,
"currency_symbol": db.get_currency_settings().get('currency_symbol', '$'),
"rotation_breakdown": rotation_breakdown,
"autoselect_breakdown": autoselect_breakdown,
}
)
from fastapi import APIRouter, Request, Form, Query, HTTPException, UploadFile, File
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, Response, StreamingResponse
from typing import Optional
import json, logging, os, time, re
from pathlib import Path
from datetime import datetime, timedelta
from aisbf.database import DatabaseRegistry
from aisbf.database import _hash_password as _db_hash_password
from aisbf import __version__
from aisbf.app.templates import url_for, get_base_url
from aisbf.app.startup import (_reload_global_config, _apply_condense_defaults_provider,
_apply_condense_defaults_rotation, _providers_json_path, _rotations_json_path,
_autoselect_json_path, get_aisbf_config_path, _user_handlers_cache, get_user_handler)
from aisbf.routes.auth import require_dashboard_auth, require_api_auth, require_api_admin, require_admin
router = APIRouter()
_config = None
_templates = None
logger = logging.getLogger(__name__)
def init(config, templates):
global _config, _templates
_config = config
_templates = templates
@router.post("/dashboard/api/provider/reorder")
async def api_provider_reorder(request: Request):
"""Persist a new display order for providers."""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
body = await request.json()
order = body.get('order', [])
if not isinstance(order, list):
return JSONResponse({"success": False, "error": "order must be a list"}, status_code=400)
if is_config_admin:
config_path = _providers_json_path()
with open(config_path) as f:
full_config = json.load(f)
providers = full_config.get('providers', full_config)
full_config['providers'] = _reorder_dict(providers, order)
save_path = Path.home() / '.aisbf' / 'providers.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
else:
db = DatabaseRegistry.get_config_database()
db.set_sort_order(current_user_id, 'provider', order)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"api_provider_reorder error: {e}", exc_info=True)
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@router.post("/dashboard/api/rotation/reorder")
async def api_rotation_reorder(request: Request):
"""Persist a new display order for rotations."""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
body = await request.json()
order = body.get('order', [])
if not isinstance(order, list):
return JSONResponse({"success": False, "error": "order must be a list"}, status_code=400)
if is_config_admin:
config_path = _rotations_json_path()
with open(config_path) as f:
full_config = json.load(f)
rotations = full_config.get('rotations', full_config)
full_config['rotations'] = _reorder_dict(rotations, order)
save_path = Path.home() / '.aisbf' / 'rotations.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
else:
db = DatabaseRegistry.get_config_database()
db.set_sort_order(current_user_id, 'rotation', order)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"api_rotation_reorder error: {e}", exc_info=True)
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@router.post("/dashboard/api/autoselect/reorder")
async def api_autoselect_reorder(request: Request):
"""Persist a new display order for autoselects."""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
body = await request.json()
order = body.get('order', [])
if not isinstance(order, list):
return JSONResponse({"success": False, "error": "order must be a list"}, status_code=400)
if is_config_admin:
config_path = _autoselect_json_path()
with open(config_path) as f:
full_config = json.load(f)
full_config = _reorder_dict(full_config, order)
save_path = Path.home() / '.aisbf' / 'autoselect.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
else:
db = DatabaseRegistry.get_config_database()
db.set_sort_order(current_user_id, 'autoselect', order)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"api_autoselect_reorder error: {e}", exc_info=True)
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@router.get("/dashboard/prompts", response_class=HTMLResponse)
async def dashboard_prompts(request: Request):
"""Edit prompt _templates"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
is_admin = request.session.get('role') == 'admin'
user_id = request.session.get('user_id')
# Define available prompts
prompt_files = [
{'key': 'condensation_conversational', 'name': 'Condensation - Conversational', 'filename': 'condensation_conversational.md'},
{'key': 'condensation_semantic', 'name': 'Condensation - Semantic', 'filename': 'condensation_semantic.md'},
{'key': 'autoselect', 'name': 'Autoselect - Model Selection', 'filename': 'autoselect.md'},
]
prompts_data = []
db = DatabaseRegistry.get_config_database()
for prompt_file in prompt_files:
content = None
# Check if regular user has saved override
if user_id:
content = db.get_user_prompt(user_id, prompt_file['key'])
# If no user override or admin, load default from filesystem
if content is None:
# Check user config first
config_path = Path.home() / '.aisbf' / prompt_file['filename']
if not config_path.exists():
# Try installed locations
installed_dirs = [
Path.home() / '.local' / 'share' / 'aisbf',
Path('/usr/share/aisbf'),
Path(__file__).parent, # For source tree
]
source_path = None
for installed_dir in installed_dirs:
test_path = installed_dir / prompt_file['filename']
if test_path.exists():
source_path = test_path
break
# Also check config subdirectory
test_path = installed_dir / 'config' / prompt_file['filename']
if test_path.exists():
source_path = test_path
break
if source_path:
# Copy to user config directory
config_path.parent.mkdir(parents=True, exist_ok=True)
import shutil
shutil.copy2(source_path, config_path)
logger.info(f"Copied prompt from {source_path} to {config_path}")
if config_path.exists():
with open(config_path) as f:
content = f.read()
else:
# Add empty prompt if file not found
content = f'# {prompt_file["name"]}\n\nPrompt template not found. Please add your prompt here.'
prompts_data.append({
'key': prompt_file['key'],
'name': prompt_file['name'],
'filename': prompt_file['filename'],
'content': content
})
# Check for success parameter
success = request.query_params.get('success')
return _templates.TemplateResponse(
request=request,
name="dashboard/prompts.html",
context={
"request": request,
"session": request.session,
"prompts": prompt_files,
"prompts_data": json.dumps(prompts_data),
"is_admin": is_admin,
"success": "Prompt saved successfully!" if success else None
}
)
@router.post("/dashboard/prompts")
async def dashboard_prompts_save(request: Request, prompt_key: str = Form(...), prompt_content: str = Form(...)):
"""Save prompt template"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
is_admin = request.session.get('role') == 'admin'
user_id = request.session.get('user_id')
# Map prompt keys to filenames
prompt_map = {
'condensation_conversational': 'condensation_conversational.md',
'condensation_semantic': 'condensation_semantic.md',
'autoselect': 'autoselect.md',
}
if prompt_key not in prompt_map:
return _templates.TemplateResponse(
request=request,
name="dashboard/prompts.html",
context={
"request": request,
"session": request.session,
"prompts": [],
"prompts_data": "[]",
"error": "Invalid prompt key"
}
)
if is_admin:
# Admin saves to filesystem
filename = prompt_map[prompt_key]
config_path = Path.home() / '.aisbf' / filename
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f:
f.write(prompt_content)
else:
# Regular user saves to database
db = DatabaseRegistry.get_config_database()
db.save_user_prompt(user_id, prompt_key, prompt_content)
return RedirectResponse(url=url_for(request, "/dashboard/prompts?success=1"), status_code=303)
@router.post("/dashboard/prompts/reset/{prompt_key}")
async def dashboard_prompts_reset(request: Request, prompt_key: str):
"""Reset prompt to default for user"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
if not user_id:
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
db = DatabaseRegistry.get_config_database()
db.delete_user_prompt(user_id, prompt_key)
return JSONResponse({"success": True})
@router.get("/dashboard/condensation", response_class=HTMLResponse)
async def dashboard_condensation(request: Request):
"""Redirect to prompts page for backward compatibility"""
return RedirectResponse(url=url_for(request, "/dashboard/prompts"), status_code=303)
@router.post("/dashboard/condensation")
async def dashboard_condensation_save(request: Request, config: str = Form(...)):
"""Save condensation prompts - backward compatibility"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
config_path = Path.home() / '.aisbf' / 'condensation_conversational.md'
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f:
f.write(config)
return RedirectResponse(url=url_for(request, "/dashboard/prompts?success=1"), status_code=303)
@router.get("/dashboard/settings", response_class=HTMLResponse)
async def dashboard_settings(request: Request):
"""Edit server settings"""
auth_check = require_admin(request)
if auth_check:
return auth_check
config_path = get_aisbf_config_path()
if not config_path.exists():
raise HTTPException(status_code=500, detail="Configuration file not found")
with open(config_path) as f:
aisbf_config = json.load(f)
# Ensure MCP config exists with defaults
if 'mcp' not in aisbf_config:
aisbf_config['mcp'] = {
'enabled': False,
'autoselect_tokens': [],
'fullconfig_tokens': []
}
warning = request.query_params.get('warning')
return _templates.TemplateResponse(
request=request,
name="dashboard/settings.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"config": aisbf_config,
"os": os,
"warning": warning,
}
)
@router.post("/dashboard/settings")
async def dashboard_settings_save(
request: Request,
host: str = Form(...),
port: int = Form(...),
protocol: str = Form(...),
auth_enabled: bool = Form(False),
auth_tokens: str = Form(""),
dashboard_username: str = Form(...),
condensation_model_id: str = Form(...),
autoselect_model_id: str = Form(...),
autoselect_max_tokens: int = Form(8000),
condensation_max_tokens: int = Form(1000),
autoselect_max_new_tokens: int = Form(100),
nsfw_classifier: str = Form("michelleli99/NSFW_text_classifier"),
privacy_classifier: str = Form("iiiorg/piiranha-v1-detect-personal-information"),
semantic_vectorization: str = Form("sentence-transformers/all-MiniLM-L6-v2"),
classify_nsfw: bool = Form(False),
classify_privacy: bool = Form(False),
classify_semantic: bool = Form(False),
batching_enabled: bool = Form(False),
batching_window_ms: int = Form(100),
batching_max_batch_size: int = Form(8),
batching_openai_enabled: bool = Form(False),
batching_openai_max_batch_size: int = Form(10),
batching_anthropic_enabled: bool = Form(False),
batching_anthropic_max_batch_size: int = Form(5),
adaptive_rate_limiting_enabled: bool = Form(False),
adaptive_initial_rate_limit: float = Form(0),
adaptive_learning_rate: float = Form(0.1),
adaptive_headroom_percent: float = Form(10),
adaptive_recovery_rate: float = Form(0.05),
adaptive_max_rate_limit: float = Form(60),
adaptive_min_rate_limit: float = Form(0.1),
adaptive_backoff_base: float = Form(2),
adaptive_jitter_factor: float = Form(0.25),
adaptive_history_window: int = Form(3600),
adaptive_consecutive_successes: int = Form(10),
active_tab: str = Form("server"),
database_type: str = Form("sqlite"),
sqlite_path: str = Form("~/.aisbf/aisbf.db"),
mysql_host: str = Form("localhost"),
mysql_port: int = Form(3306),
mysql_user: str = Form("aisbf"),
mysql_password: str = Form(""),
mysql_database: str = Form("aisbf"),
cache_type: str = Form("file"),
redis_host: str = Form("localhost"),
redis_port: int = Form(6379),
redis_db: int = Form(0),
redis_password: str = Form(""),
redis_key_prefix: str = Form("aisbf:"),
response_cache_enabled: bool = Form(False),
response_cache_backend: str = Form("memory"),
response_cache_ttl: int = Form(600),
response_cache_max_memory: int = Form(1000),
response_cache_redis_host: str = Form("localhost"),
response_cache_redis_port: int = Form(6379),
response_cache_redis_db: int = Form(0),
response_cache_redis_password: str = Form(""),
response_cache_redis_key_prefix: str = Form("aisbf:response:"),
response_cache_sqlite_path: str = Form("~/.aisbf/response_cache.db"),
response_cache_mysql_host: str = Form("localhost"),
response_cache_mysql_port: int = Form(3306),
response_cache_mysql_user: str = Form("aisbf"),
response_cache_mysql_password: str = Form(""),
response_cache_mysql_database: str = Form("aisbf_response_cache"),
mcp_enabled: bool = Form(False),
autoselect_tokens: str = Form(""),
fullconfig_tokens: str = Form(""),
tor_enabled: bool = Form(False),
tor_control_port: int = Form(9051),
tor_control_host: str = Form("127.0.0.1"),
tor_control_password: str = Form(""),
tor_hidden_service_dir: str = Form(""),
tor_hidden_service_port: int = Form(80),
tor_socks_port: int = Form(9050),
tor_socks_host: str = Form("127.0.0.1"),
signup_enabled: bool = Form(False),
signup_require_verification: bool = Form(False),
verification_token_expiry: int = Form(24),
smtp_host: str = Form(""),
smtp_port: int = Form(587),
smtp_username: str = Form(""),
smtp_password: str = Form(""),
smtp_use_tls: bool = Form(True),
smtp_use_ssl: bool = Form(False),
smtp_from_email: str = Form(""),
smtp_from_name: str = Form(""),
oauth2_google_enabled: bool = Form(False),
oauth2_google_client_id: str = Form(""),
oauth2_google_client_secret: str = Form(""),
oauth2_github_enabled: bool = Form(False),
oauth2_github_client_id: str = Form(""),
oauth2_github_client_secret: str = Form(""),
smtp_enabled: bool = Form(False),
dashboard_email: str = Form(""),
admin_notify_new_user_signup: bool = Form(False),
admin_notify_payment_received: bool = Form(False),
admin_notify_tier_upgrade: bool = Form(False),
admin_notify_tier_downgrade: bool = Form(False),
admin_notify_subscription_expired: bool = Form(False),
admin_notify_subscription_renewed: bool = Form(False),
admin_notify_wallet_topup: bool = Form(False),
admin_notify_user_deleted_account: bool = Form(False),
new_admin_password: str = Form(""),
confirm_admin_password: str = Form(""),
client_rl_enabled: bool = Form(False),
client_rl_api_rpm: int = Form(60),
client_rl_api_rph: int = Form(1000),
client_rl_general_rpm: int = Form(120),
client_rl_general_rph: int = Form(3000)
):
"""Save server settings"""
auth_check = require_admin(request)
if auth_check:
return auth_check
# Load current config
config_path = get_aisbf_config_path()
if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'aisbf.json'
with open(config_path) as f:
aisbf_config = json.load(f)
# Update config
aisbf_config['server']['host'] = host
aisbf_config['server']['port'] = port
aisbf_config['server']['protocol'] = protocol
aisbf_config['auth']['enabled'] = auth_enabled
aisbf_config['auth']['tokens'] = [t.strip() for t in auth_tokens.split('\n') if t.strip()]
aisbf_config['dashboard']['username'] = dashboard_username
aisbf_config['internal_model']['condensation_model_id'] = condensation_model_id
aisbf_config['internal_model']['autoselect_model_id'] = autoselect_model_id
aisbf_config['internal_model']['autoselect_max_tokens'] = max(256, autoselect_max_tokens)
aisbf_config['internal_model']['condensation_max_tokens'] = max(64, condensation_max_tokens)
aisbf_config['internal_model']['autoselect_max_new_tokens'] = max(16, autoselect_max_new_tokens)
# Update database config
if 'database' not in aisbf_config:
aisbf_config['database'] = {}
aisbf_config['database']['type'] = database_type
aisbf_config['database']['sqlite_path'] = sqlite_path
aisbf_config['database']['mysql_host'] = mysql_host
aisbf_config['database']['mysql_port'] = mysql_port
aisbf_config['database']['mysql_user'] = mysql_user
if mysql_password: # Only update if provided
aisbf_config['database']['mysql_password'] = mysql_password
aisbf_config['database']['mysql_database'] = mysql_database
# Update cache config
if 'cache' not in aisbf_config:
aisbf_config['cache'] = {}
aisbf_config['cache']['type'] = cache_type
aisbf_config['cache']['redis_host'] = redis_host
aisbf_config['cache']['redis_port'] = redis_port
aisbf_config['cache']['redis_db'] = redis_db
if redis_password: # Only update if provided
aisbf_config['cache']['redis_password'] = redis_password
aisbf_config['cache']['redis_key_prefix'] = redis_key_prefix
# Update response cache config
if 'response_cache' not in aisbf_config:
aisbf_config['response_cache'] = {}
aisbf_config['response_cache']['enabled'] = response_cache_enabled
aisbf_config['response_cache']['backend'] = response_cache_backend
aisbf_config['response_cache']['ttl'] = response_cache_ttl
aisbf_config['response_cache']['max_memory_cache'] = response_cache_max_memory
# Response cache Redis settings
aisbf_config['response_cache']['redis_host'] = response_cache_redis_host
aisbf_config['response_cache']['redis_port'] = response_cache_redis_port
aisbf_config['response_cache']['redis_db'] = response_cache_redis_db
if response_cache_redis_password: # Only update if provided
aisbf_config['response_cache']['redis_password'] = response_cache_redis_password
aisbf_config['response_cache']['redis_key_prefix'] = response_cache_redis_key_prefix
# Response cache SQLite settings
aisbf_config['response_cache']['sqlite_path'] = response_cache_sqlite_path
# Response cache MySQL settings
aisbf_config['response_cache']['mysql_host'] = response_cache_mysql_host
aisbf_config['response_cache']['mysql_port'] = response_cache_mysql_port
aisbf_config['response_cache']['mysql_user'] = response_cache_mysql_user
if response_cache_mysql_password: # Only update if provided
aisbf_config['response_cache']['mysql_password'] = response_cache_mysql_password
aisbf_config['response_cache']['mysql_database'] = response_cache_mysql_database
# Update MCP config
if 'mcp' not in aisbf_config:
aisbf_config['mcp'] = {}
aisbf_config['mcp']['enabled'] = mcp_enabled
aisbf_config['mcp']['autoselect_tokens'] = [t.strip() for t in autoselect_tokens.split('\n') if t.strip()]
aisbf_config['mcp']['fullconfig_tokens'] = [t.strip() for t in fullconfig_tokens.split('\n') if t.strip()]
# Update TOR config
if 'tor' not in aisbf_config:
aisbf_config['tor'] = {}
aisbf_config['tor']['enabled'] = tor_enabled
aisbf_config['tor']['control_port'] = tor_control_port
aisbf_config['tor']['control_host'] = tor_control_host
aisbf_config['tor']['control_password'] = tor_control_password if tor_control_password else None
aisbf_config['tor']['hidden_service_dir'] = tor_hidden_service_dir if tor_hidden_service_dir else None
aisbf_config['tor']['hidden_service_port'] = tor_hidden_service_port
aisbf_config['tor']['socks_port'] = tor_socks_port
aisbf_config['tor']['socks_host'] = tor_socks_host
# Update Signup config
if 'signup' not in aisbf_config:
aisbf_config['signup'] = {}
aisbf_config['signup']['enabled'] = signup_enabled
aisbf_config['signup']['require_email_verification'] = signup_require_verification
aisbf_config['signup']['verification_token_expiry_hours'] = verification_token_expiry
# Update SMTP config
if 'smtp' not in aisbf_config:
aisbf_config['smtp'] = {}
aisbf_config['smtp']['enabled'] = smtp_enabled
aisbf_config['smtp']['host'] = smtp_host
aisbf_config['smtp']['port'] = smtp_port
aisbf_config['smtp']['username'] = smtp_username
# Preserve existing password if submitted field is empty
if smtp_password:
aisbf_config['smtp']['password'] = smtp_password
elif 'password' not in aisbf_config['smtp']:
# Initialize as empty if not exists
aisbf_config['smtp']['password'] = ""
aisbf_config['smtp']['use_tls'] = smtp_use_tls
aisbf_config['smtp']['use_ssl'] = smtp_use_ssl
aisbf_config['smtp']['from_email'] = smtp_from_email
aisbf_config['smtp']['from_name'] = smtp_from_name
# Update OAuth2 config
if 'oauth2' not in aisbf_config:
aisbf_config['oauth2'] = {}
# Google OAuth2
if 'google' not in aisbf_config['oauth2']:
aisbf_config['oauth2']['google'] = {}
aisbf_config['oauth2']['google']['enabled'] = oauth2_google_enabled
aisbf_config['oauth2']['google']['client_id'] = oauth2_google_client_id
# Preserve existing client_secret if submitted field is empty
if oauth2_google_client_secret:
aisbf_config['oauth2']['google']['client_secret'] = oauth2_google_client_secret
elif 'client_secret' not in aisbf_config['oauth2']['google']:
aisbf_config['oauth2']['google']['client_secret'] = ""
aisbf_config['oauth2']['google']['scopes'] = [
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile"
]
# GitHub OAuth2
if 'github' not in aisbf_config['oauth2']:
aisbf_config['oauth2']['github'] = {}
aisbf_config['oauth2']['github']['enabled'] = oauth2_github_enabled
aisbf_config['oauth2']['github']['client_id'] = oauth2_github_client_id
# Preserve existing client_secret if submitted field is empty
if oauth2_github_client_secret:
aisbf_config['oauth2']['github']['client_secret'] = oauth2_github_client_secret
elif 'client_secret' not in aisbf_config['oauth2']['github']:
aisbf_config['oauth2']['github']['client_secret'] = ""
aisbf_config['oauth2']['github']['scopes'] = ["user:email", "read:user"]
# Update admin email and notification preferences
if 'dashboard' not in aisbf_config:
aisbf_config['dashboard'] = {}
if dashboard_email:
aisbf_config['dashboard']['email'] = dashboard_email
elif 'email' not in aisbf_config['dashboard']:
aisbf_config['dashboard']['email'] = ""
if 'notifications' not in aisbf_config['dashboard']:
aisbf_config['dashboard']['notifications'] = {}
aisbf_config['dashboard']['notifications']['new_user_signup'] = admin_notify_new_user_signup
aisbf_config['dashboard']['notifications']['payment_received'] = admin_notify_payment_received
aisbf_config['dashboard']['notifications']['tier_upgrade'] = admin_notify_tier_upgrade
aisbf_config['dashboard']['notifications']['tier_downgrade'] = admin_notify_tier_downgrade
aisbf_config['dashboard']['notifications']['subscription_expired'] = admin_notify_subscription_expired
aisbf_config['dashboard']['notifications']['subscription_renewed'] = admin_notify_subscription_renewed
aisbf_config['dashboard']['notifications']['wallet_topup'] = admin_notify_wallet_topup
aisbf_config['dashboard']['notifications']['user_deleted_account'] = admin_notify_user_deleted_account
if new_admin_password:
if new_admin_password == confirm_admin_password:
aisbf_config['dashboard']['password'] = _db_hash_password(new_admin_password)
request.session.pop('must_change_password', None)
# Update classification config
aisbf_config['classify_nsfw'] = classify_nsfw
aisbf_config['classify_privacy'] = classify_privacy
aisbf_config['classify_semantic'] = classify_semantic
# Update internal model classifiers
if 'internal_model' not in aisbf_config:
aisbf_config['internal_model'] = {}
aisbf_config['internal_model']['nsfw_classifier'] = nsfw_classifier
aisbf_config['internal_model']['privacy_classifier'] = privacy_classifier
aisbf_config['internal_model']['semantic_vectorization'] = semantic_vectorization
# Update batching config
if 'batching' not in aisbf_config:
aisbf_config['batching'] = {}
aisbf_config['batching']['enabled'] = batching_enabled
aisbf_config['batching']['window_ms'] = batching_window_ms
aisbf_config['batching']['max_batch_size'] = batching_max_batch_size
if 'provider_settings' not in aisbf_config['batching']:
aisbf_config['batching']['provider_settings'] = {}
if 'openai' not in aisbf_config['batching']['provider_settings']:
aisbf_config['batching']['provider_settings']['openai'] = {}
aisbf_config['batching']['provider_settings']['openai']['enabled'] = batching_openai_enabled
aisbf_config['batching']['provider_settings']['openai']['max_batch_size'] = batching_openai_max_batch_size
if 'anthropic' not in aisbf_config['batching']['provider_settings']:
aisbf_config['batching']['provider_settings']['anthropic'] = {}
aisbf_config['batching']['provider_settings']['anthropic']['enabled'] = batching_anthropic_enabled
aisbf_config['batching']['provider_settings']['anthropic']['max_batch_size'] = batching_anthropic_max_batch_size
# Update adaptive rate limiting config
if 'adaptive_rate_limiting' not in aisbf_config:
aisbf_config['adaptive_rate_limiting'] = {}
aisbf_config['adaptive_rate_limiting']['enabled'] = adaptive_rate_limiting_enabled
aisbf_config['adaptive_rate_limiting']['initial_rate_limit'] = adaptive_initial_rate_limit
aisbf_config['adaptive_rate_limiting']['learning_rate'] = adaptive_learning_rate
aisbf_config['adaptive_rate_limiting']['headroom_percent'] = adaptive_headroom_percent
aisbf_config['adaptive_rate_limiting']['recovery_rate'] = adaptive_recovery_rate
aisbf_config['adaptive_rate_limiting']['max_rate_limit'] = adaptive_max_rate_limit
aisbf_config['adaptive_rate_limiting']['min_rate_limit'] = adaptive_min_rate_limit
aisbf_config['adaptive_rate_limiting']['backoff_base'] = adaptive_backoff_base
aisbf_config['adaptive_rate_limiting']['jitter_factor'] = adaptive_jitter_factor
aisbf_config['adaptive_rate_limiting']['history_window'] = adaptive_history_window
aisbf_config['adaptive_rate_limiting']['consecutive_successes_for_recovery'] = adaptive_consecutive_successes
# Update client rate limiting config
if 'client_rate_limiting' not in aisbf_config:
aisbf_config['client_rate_limiting'] = {}
aisbf_config['client_rate_limiting']['enabled'] = client_rl_enabled
aisbf_config['client_rate_limiting']['api'] = {
'requests_per_minute': max(0, client_rl_api_rpm),
'requests_per_hour': max(0, client_rl_api_rph)
}
aisbf_config['client_rate_limiting']['general'] = {
'requests_per_minute': max(0, client_rl_general_rpm),
'requests_per_hour': max(0, client_rl_general_rph)
}
# Save config
config_path = Path.home() / '.aisbf' / 'aisbf.json'
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f:
json.dump(aisbf_config, f, indent=2)
# Reload dashboard credentials in memory so the new username/password takes effect immediately
if server_config is not None:
server_config['dashboard_config'] = aisbf_config.get('dashboard', {})
# Hot-reload global config so changes take effect without restart
_reload_global_config()
return _templates.TemplateResponse(
request=request,
name="dashboard/settings.html",
context={
"request": request,
"session": request.session,
"config": aisbf_config,
"os": os,
"active_tab": active_tab,
"success": "Settings saved and reloaded successfully."
}
)
@router.post("/dashboard/test-smtp")
async def dashboard_test_smtp(request: Request):
auth_check = require_admin(request)
if auth_check:
return auth_check
try:
body = await request.json()
from aisbf.email_utils import send_test_email
# Send test email to specified recipient
test_recipient = body.get('test_recipient')
if not test_recipient:
return JSONResponse({"success": False, "error": "Test recipient email is required"})
# Load the actual saved SMTP config from aisbf.json
config_path = get_aisbf_config_path()
if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'aisbf.json'
with open(config_path) as f:
aisbf_config = json.load(f)
smtp_config = aisbf_config.get('smtp', {})
result = send_test_email(test_recipient, smtp_config)
if result:
return JSONResponse({"success": True})
else:
return JSONResponse({"success": False, "error": "Failed to send test email"})
except Exception as e:
logger.error(f"Error testing SMTP: {e}")
return JSONResponse({"success": False, "error": str(e)})
# Admin user management routes
@router.get("/dashboard/users", response_class=HTMLResponse)
async def dashboard_users(
request: Request,
page: int = Query(1, ge=1),
limit: int = Query(25, ge=1, le=100),
search: str = Query(None, max_length=100),
order_by: str = Query('created_at', regex='^(username|last_login|created_at|tier_name)$'),
direction: str = Query('desc', regex='^(asc|desc)$'),
status_filter: str = Query(None, regex='^(active|inactive)$'),
role_filter: str = Query(None, regex='^(admin|user)$')
):
"""Admin user management page"""
auth_check = require_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
# Get paginated users
result = db.get_users_paginated(
page=page,
limit=limit,
search=search,
order_by=order_by,
direction=direction,
status_filter=status_filter,
role_filter=role_filter
)
users = result['users']
total_users = result['total']
# Calculate pagination metadata
total_pages = (total_users + limit - 1) // limit # Ceiling division
current_page = min(page, total_pages) if total_pages > 0 else 1
start_item = (current_page - 1) * limit + 1
end_item = min(current_page * limit, total_users)
# Get all tiers for assignment dropdown
tiers = db.get_all_tiers()
return _templates.TemplateResponse(
request=request,
name="dashboard/users.html",
context={
"request": request,
"session": request.session,
"users": users,
"tiers": tiers,
"pagination": {
"current_page": current_page,
"total_pages": total_pages,
"total_users": total_users,
"start_item": start_item,
"end_item": end_item,
"limit": limit,
"has_prev": current_page > 1,
"has_next": current_page < total_pages
},
"filters": {
"search": search or "",
"order_by": order_by,
"direction": direction,
"status_filter": status_filter,
"role_filter": role_filter
}
}
)
@router.post("/dashboard/users/add")
async def dashboard_users_add(request: Request, username: str = Form(...), password: str = Form(...), role: str = Form("user")):
"""Add a new user"""
auth_check = require_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
password_hash = _db_hash_password(password)
try:
# Get current admin username
admin_username = request.session.get('username', 'admin')
# Create user with display_name defaulting to username
user_id = db.create_user(username, password_hash, role, admin_username, None, False, username)
return RedirectResponse(url=url_for(request, "/dashboard/users"), status_code=303)
except Exception as e:
users = db.get_users()
return _templates.TemplateResponse(
request=request,
name="dashboard/users.html",
context={
"request": request,
"session": request.session,
"users": users,
"error": f"Failed to create user: {str(e)}"
}
)
@router.post("/dashboard/users/{user_id}/edit")
async def dashboard_users_edit(request: Request, user_id: int, username: str = Form(...), password: str = Form(""), role: str = Form("user"), is_active: bool = Form(True)):
"""Edit an existing user"""
auth_check = require_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
try:
# Update user (only if password is provided)
if password:
password_hash = _db_hash_password(password)
db.update_user(user_id, username, password_hash, role, is_active, username)
else:
db.update_user(user_id, username, None, role, is_active, username)
return RedirectResponse(url=url_for(request, "/dashboard/users"), status_code=303)
except Exception as e:
users = db.get_users()
return _templates.TemplateResponse(
request=request,
name="dashboard/users.html",
context={
"request": request,
"session": request.session,
"users": users,
"error": f"Failed to update user: {str(e)}"
}
)
@router.post("/dashboard/users/{user_id}/toggle")
async def dashboard_users_toggle(request: Request, user_id: int):
"""Toggle user active status"""
auth_check = require_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
try:
users = db.get_users()
for user in users:
if user['id'] == user_id:
new_status = not user['is_active']
db.update_user(user_id, user['username'], None, user['role'], new_status)
return JSONResponse({"success": True})
return JSONResponse({"success": False, "error": "User not found"}, status_code=404)
except Exception as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@router.post("/dashboard/users/{user_id}/delete")
async def dashboard_users_delete(request: Request, user_id: int):
"""Delete a user"""
auth_check = require_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
try:
db.delete_user(user_id)
return JSONResponse({"success": True})
except Exception as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@router.post("/dashboard/users/{user_id}/impersonate")
async def dashboard_users_impersonate(request: Request, user_id: int):
"""Impersonate a user (admin only). Saves admin session and switches to target user."""
auth_check = require_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
user = db.get_user_by_id(user_id)
if not user:
return JSONResponse({"success": False, "error": "User not found"}, status_code=404)
# Save current admin session so we can restore it on logout
request.session['impersonating_as'] = user_id
request.session['admin_session'] = {
'logged_in': request.session.get('logged_in'),
'username': request.session.get('username'),
'display_name': request.session.get('display_name'),
'email': request.session.get('email'),
'role': request.session.get('role'),
'user_id': request.session.get('user_id'),
'has_profile_pic': request.session.get('has_profile_pic'),
'remember_me': request.session.get('remember_me'),
'expires_at': request.session.get('expires_at'),
'email_verified': request.session.get('email_verified'),
}
# Switch session to target user
request.session['logged_in'] = True
request.session['username'] = user['username']
request.session['display_name'] = user.get('display_name') or ''
request.session['email'] = user.get('email') or ''
request.session['role'] = user['role']
request.session['user_id'] = user['id']
request.session['has_profile_pic'] = bool(user.get('profile_pic'))
request.session['email_verified'] = user.get('email_verified', False)
request.session['must_change_password'] = False
return JSONResponse({"success": True, "redirect": url_for(request, "/dashboard")})
@router.post("/dashboard/users/{user_id}/tier")
async def dashboard_users_update_tier(request: Request, user_id: int):
"""Update user tier assignment"""
auth_check = require_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
try:
body = await request.json()
tier_id = body.get('tier_id')
if not tier_id:
return JSONResponse({"success": False, "error": "tier_id is required"}, status_code=400)
# Verify tier exists
tier = db.get_tier_by_id(tier_id)
if not tier:
return JSONResponse({"success": False, "error": "Tier not found"}, status_code=404)
# Update user tier
success = db.set_user_tier(user_id, tier_id)
if success:
return JSONResponse({"success": True})
else:
return JSONResponse({"success": False, "error": "Failed to update user tier"}, status_code=500)
except Exception as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@router.post("/dashboard/users/bulk")
async def dashboard_users_bulk(request: Request):
"""Handle bulk user operations"""
auth_check = require_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
try:
body = await request.json()
action = body.get('action')
user_ids = body.get('user_ids', [])
extra_data = body.get('extra_data')
if not action or not user_ids:
return JSONResponse({"success": False, "error": "Action and user_ids required"}, status_code=400)
# Validate user_ids
if not isinstance(user_ids, list) or not all(isinstance(uid, int) for uid in user_ids):
return JSONResponse({"success": False, "error": "user_ids must be a list of integers"}, status_code=400)
if action == 'enable':
success_count = 0
for user_id in user_ids:
if db.update_user(user_id, None, None, None, True):
success_count += 1
return JSONResponse({"success": True, "message": f"Enabled {success_count} of {len(user_ids)} users"})
elif action == 'disable':
success_count = 0
for user_id in user_ids:
if db.update_user(user_id, None, None, None, False):
success_count += 1
return JSONResponse({"success": True, "message": f"Disabled {success_count} of {len(user_ids)} users"})
elif action == 'delete':
success_count = 0
for user_id in user_ids:
try:
db.delete_user(user_id)
success_count += 1
except Exception:
pass # Continue with other deletions
return JSONResponse({"success": True, "message": f"Deleted {success_count} of {len(user_ids)} users"})
elif action == 'tier':
tier_id = extra_data
if not tier_id:
return JSONResponse({"success": False, "error": "tier_id required for tier action"}, status_code=400)
# Verify tier exists
tier = db.get_tier_by_id(tier_id)
if not tier:
return JSONResponse({"success": False, "error": "Tier not found"}, status_code=404)
success_count = 0
for user_id in user_ids:
if db.set_user_tier(user_id, tier_id):
success_count += 1
return JSONResponse({"success": True, "message": f"Changed tier for {success_count} of {len(user_ids)} users"})
else:
return JSONResponse({"success": False, "error": "Invalid action"}, status_code=400)
except Exception as e:
logger.error(f"Bulk operation error: {e}")
return JSONResponse({"success": False, "error": "Internal server error"}, status_code=500)
@router.post("/dashboard/api/admin/notifications/send")
async def admin_send_notification(request: Request):
"""Admin sends an in-app notification to selected users."""
auth_check = require_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
try:
body = await request.json()
user_ids = body.get('user_ids', [])
title = (body.get('title') or '').strip()
message = (body.get('message') or '').strip()
if not user_ids or not title or not message:
return JSONResponse({"success": False, "error": "user_ids, title and message are required"}, status_code=400)
if not isinstance(user_ids, list) or not all(isinstance(uid, int) for uid in user_ids):
return JSONResponse({"success": False, "error": "user_ids must be a list of integers"}, status_code=400)
sent = 0
for uid in user_ids:
try:
db.create_notification(uid, title, message, 'admin_message')
sent += 1
except Exception:
pass
return JSONResponse({"success": True, "sent": sent})
except Exception as e:
logger.error(f"admin_send_notification: {e}")
return JSONResponse({"success": False, "error": "Internal server error"}, status_code=500)
@router.get("/dashboard/api/notifications")
async def get_notifications(request: Request):
"""Return in-app notifications for the current user."""
if not request.session.get('logged_in') or not request.session.get('user_id'):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
user_id = request.session['user_id']
db = DatabaseRegistry.get_config_database()
notifications = db.get_user_notifications(user_id, limit=50)
return JSONResponse({"notifications": notifications})
@router.get("/dashboard/api/notifications/count")
async def get_notification_count(request: Request):
"""Return unread notification count for the current user."""
if not request.session.get('logged_in') or not request.session.get('user_id'):
return JSONResponse({"count": 0})
user_id = request.session['user_id']
db = DatabaseRegistry.get_config_database()
count = db.get_unread_notification_count(user_id)
return JSONResponse({"count": count})
@router.post("/dashboard/api/notifications/{notification_id}/read")
async def mark_notification_read(request: Request, notification_id: int):
"""Mark a single notification as read."""
if not request.session.get('logged_in') or not request.session.get('user_id'):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
user_id = request.session['user_id']
db = DatabaseRegistry.get_config_database()
db.mark_notification_read(notification_id, user_id)
return JSONResponse({"success": True})
@router.post("/dashboard/api/notifications/read-all")
async def mark_all_notifications_read(request: Request):
"""Mark all notifications as read for the current user."""
if not request.session.get('logged_in') or not request.session.get('user_id'):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
user_id = request.session['user_id']
db = DatabaseRegistry.get_config_database()
db.mark_all_notifications_read(user_id)
return JSONResponse({"success": True})
@router.delete("/dashboard/api/notifications/{notification_id}")
async def delete_notification(request: Request, notification_id: int):
"""Delete a notification for the current user."""
if not request.session.get('logged_in') or not request.session.get('user_id'):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
user_id = request.session['user_id']
db = DatabaseRegistry.get_config_database()
db.delete_notification(notification_id, user_id)
return JSONResponse({"success": True})
@router.post("/dashboard/restart")
async def dashboard_restart(request: Request):
"""Reload configuration from disk"""
auth_check = require_admin(request)
if auth_check:
return auth_check
logger.info("Configuration reload requested from dashboard")
try:
# Reload configuration
from aisbf.config import config
config.reload()
# Re-initialize database if _config changed
db_config = config.aisbf.database if _config.aisbf and config.aisbf.database else None
if db_config:
from aisbf.database import DatabaseRegistry
DatabaseRegistry.get_config_database(db_config)
# Re-initialize cache if _config changed
cache_config = config.aisbf.cache if _config.aisbf and config.aisbf.cache else None
if cache_config:
from aisbf.cache import initialize_cache
initialize_cache(cache_config)
# Re-initialize response cache if _config changed
response_cache_config = config.aisbf.response_cache if _config.aisbf and config.aisbf.response_cache else None
if response_cache_config:
from aisbf.cache import initialize_response_cache
initialize_response_cache(response_cache_config.model_dump() if hasattr(response_cache_config, 'model_dump') else response_cache_config)
logger.info("Configuration reloaded successfully")
return JSONResponse({"message": "Configuration reloaded successfully. All changes have been applied."})
except Exception as e:
logger.error(f"Error reloading configuration: {e}")
return JSONResponse({"error": f"Failed to reload configuration: {str(e)}"}, status_code=500)
# User-specific configuration management routes
db = DatabaseRegistry.get_config_database()
try:
file_info = db.get_user_auth_file(user_id, provider_name, file_type)
if file_info:
# Delete file from disk
file_path = Path(file_info['file_path'])
if file_path.exists():
file_path.unlink()
# Delete from database
db.delete_user_auth_file(user_id, provider_name, file_type)
return JSONResponse({"message": "File deleted successfully"})
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
# Admin authentication file management routes
def get_admin_auth_files_dir() -> Path:
"""Get the directory for admin authentication files"""
auth_files_dir = Path.home() / '.aisbf' / 'admin_auth_files'
auth_files_dir.mkdir(parents=True, exist_ok=True)
return auth_files_dir
@router.post("/dashboard/providers/{provider_name}/upload")
async def dashboard_provider_upload(
request: Request,
provider_name: str,
file_type: str = Form(...),
file: UploadFile = File(...)
):
"""Upload authentication file for a provider. Config admin saves to files, other users save to database."""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
# Check if current user is config admin (from aisbf.json)
is_config_admin = request.session.get('user_id') is None
logger.info(f"🔍 UPLOAD HANDLER DEBUG: session.user_id = {request.session.get('user_id')}, is_config_admin = {is_config_admin}")
try:
# Validate file type
allowed_types = ['credentials', 'database', 'config', 'kiro_credentials', 'claude_credentials', 'sqlite_db', 'creds_file', 'cli_credentials']
if file_type not in allowed_types:
return JSONResponse(
status_code=400,
content={"error": f"Invalid file type. Allowed: {', '.join(allowed_types)}"}
)
# Read file content
content = await file.read()
logger.info(f"🔍 UPLOAD HANDLER: Received file {file.filename}, size: {len(content)} bytes")
if is_config_admin:
# Config admin: save to files
auth_files_dir = get_admin_auth_files_dir()
# Generate unique filename
import uuid
file_ext = Path(file.filename).suffix if file.filename else '.json'
stored_filename = f"{provider_name}_{file_type}_{uuid.uuid4().hex[:8]}{file_ext}"
file_path = auth_files_dir / stored_filename
# Save file
with open(file_path, 'wb') as f:
f.write(content)
logger.info(f"Config admin uploaded auth file: {file_path}")
# Update providers.json with full path to the uploaded file
try:
config_path = Path.home() / '.aisbf' / 'providers.json'
if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'providers.json'
with open(config_path) as f:
full_config = json.load(f)
# Navigate to the provider config
if 'providers' in full_config and isinstance(full_config['providers'], dict):
providers = full_config['providers']
else:
providers = full_config
# Update the file path in provider config
if provider_name in providers:
# Convert absolute path to ~/... format
relative_path = str(file_path).replace(str(Path.home()), '~')
# Update the correct location based on provider type
provider_type = providers[provider_name].get('type', '')
# For Kiro, update kiro_config.sqlite_db or kiro_config.creds_file
if provider_type == 'kiro' and file_type in ['sqlite_db', 'creds_file']:
if 'kiro_config' not in providers[provider_name]:
providers[provider_name]['kiro_config'] = {}
providers[provider_name]['kiro_config'][file_type] = relative_path
logger.info(f"Updated providers.json: {provider_name}.kiro_config.{file_type} = {relative_path}")
# For Claude, update claude_config.credentials_file
elif provider_type == 'claude' and file_type in ['credentials_file', 'claude_credentials']:
if 'claude_config' not in providers[provider_name]:
providers[provider_name]['claude_config'] = {}
providers[provider_name]['claude_config']['credentials_file'] = relative_path
logger.info(f"Updated providers.json: {provider_name}.claude_config.credentials_file = {relative_path}")
# For Kilo, update kilo_config.creds_file
elif provider_type in ['kilo', 'kilocode'] and file_type in ['credentials_file', 'creds_file']:
if 'kilo_config' not in providers[provider_name]:
providers[provider_name]['kilo_config'] = {}
providers[provider_name]['kilo_config']['creds_file'] = relative_path
logger.info(f"Updated providers.json: {provider_name}.kilo_config.creds_file = {relative_path}")
# For Qwen, update qwen_config.credentials_file
elif provider_type == 'qwen' and file_type in ['credentials_file']:
if 'qwen_config' not in providers[provider_name]:
providers[provider_name]['qwen_config'] = {}
providers[provider_name]['qwen_config']['credentials_file'] = relative_path
logger.info(f"Updated providers.json: {provider_name}.qwen_config.credentials_file = {relative_path}")
# For Codex, update codex_config.credentials_file
elif provider_type == 'codex' and file_type in ['credentials_file', 'creds_file']:
if 'codex_config' not in providers[provider_name]:
providers[provider_name]['codex_config'] = {}
providers[provider_name]['codex_config']['credentials_file'] = relative_path
logger.info(f"Updated providers.json: {provider_name}.codex_config.credentials_file = {relative_path}")
# Fallback: update top-level field
else:
providers[provider_name][file_type] = relative_path
logger.info(f"Updated providers.json: {provider_name}.{file_type} = {relative_path}")
# Save updated config
save_path = Path.home() / '.aisbf' / 'providers.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
except Exception as e:
logger.error(f"Failed to update providers.json: {e}")
return JSONResponse({
"success": True,
"message": "File uploaded successfully",
"file_path": str(file_path),
"stored_filename": stored_filename
})
else:
# Database user: save to database
db = DatabaseRegistry.get_config_database()
# Get user auth files directory
auth_files_dir = get_user_auth_files_dir(current_user_id)
# Generate unique filename
import uuid
file_ext = Path(file.filename).suffix if file.filename else '.json'
stored_filename = f"{provider_name}_{file_type}_{uuid.uuid4().hex[:8]}{file_ext}"
file_path = auth_files_dir / stored_filename
# Save file
with open(file_path, 'wb') as f:
f.write(content)
# Save metadata to database
file_id = db.save_user_auth_file(
user_id=current_user_id,
provider_id=provider_name,
file_type=file_type,
original_filename=file.filename or stored_filename,
stored_filename=stored_filename,
file_path=str(file_path),
file_size=len(content),
mime_type=file.content_type
)
logger.info(f"User {current_user_id} uploaded auth file: {file_path}")
return JSONResponse({
"success": True,
"message": "File uploaded successfully",
"file_id": file_id,
"file_path": str(file_path),
"stored_filename": stored_filename
})
except Exception as e:
logger.error(f"Error uploading file: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.post("/dashboard/providers/upload-auth-file")
async def dashboard_provider_upload_form(
request: Request,
provider_key: str = Form(...),
file_type: str = Form(...),
file: UploadFile = File(...)
):
"""Upload authentication file for a provider (form variant used by frontend)."""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
# Check if current user is config admin (from aisbf.json)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
# Validate file type
allowed_types = ['credentials', 'database', 'config', 'kiro_credentials', 'claude_credentials', 'sqlite_db', 'creds_file', 'cli_credentials']
if file_type not in allowed_types:
return JSONResponse(
status_code=400,
content={"success": False, "error": f"Invalid file type. Allowed: {', '.join(allowed_types)}"}
)
# Read file content
content = await file.read()
if is_config_admin:
# Config admin: save to files
auth_files_dir = get_admin_auth_files_dir()
# Generate unique filename
import uuid
file_ext = Path(file.filename).suffix if file.filename else '.json'
stored_filename = f"{provider_key}_{file_type}_{uuid.uuid4().hex[:8]}{file_ext}"
file_path = auth_files_dir / stored_filename
# Save file
with open(file_path, 'wb') as f:
f.write(content)
logger.info(f"Config admin uploaded auth file: {file_path}")
return JSONResponse({
"success": True,
"message": "File uploaded successfully",
"file_path": str(file_path),
"stored_filename": stored_filename
})
else:
# Database user: save to database
db = DatabaseRegistry.get_config_database()
# Get user auth files directory
auth_files_dir = get_user_auth_files_dir(current_user_id)
# Generate unique filename
import uuid
file_ext = Path(file.filename).suffix if file.filename else '.json'
stored_filename = f"{provider_key}_{file_type}_{uuid.uuid4().hex[:8]}{file_ext}"
file_path = auth_files_dir / stored_filename
# Save file
with open(file_path, 'wb') as f:
f.write(content)
# Save metadata to database
file_id = db.save_user_auth_file(
user_id=current_user_id,
provider_id=provider_key,
file_type=file_type,
original_filename=file.filename or stored_filename,
stored_filename=stored_filename,
file_path=str(file_path),
file_size=len(content),
mime_type=file.content_type
)
logger.info(f"User {current_user_id} uploaded auth file: {file_path}")
return JSONResponse({
"success": True,
"message": "File uploaded successfully",
"file_id": file_id,
"file_path": str(file_path),
"stored_filename": stored_filename
})
except Exception as e:
logger.error(f"Error uploading file: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.post("/dashboard/providers/upload-auth-file/chunk")
async def dashboard_provider_upload_chunk(
request: Request,
provider_key: str = Form(...),
file_type: str = Form(...),
file_name: str = Form(...),
chunk_number: int = Form(...),
total_chunks: int = Form(...),
total_size: int = Form(...),
file: UploadFile = File(...)
):
"""Chunked file upload endpoint - handles very large files behind proxies."""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
# Validate file type
allowed_types = ['credentials', 'database', 'config', 'kiro_credentials', 'claude_credentials', 'sqlite_db', 'creds_file', 'cli_credentials']
if file_type not in allowed_types:
return JSONResponse(
status_code=400,
content={"success": False, "error": f"Invalid file type. Allowed: {', '.join(allowed_types)}"}
)
import uuid
import hashlib
# Generate unique upload ID
upload_id = hashlib.sha256(f"{current_user_id}:{provider_key}:{file_name}:{total_size}".encode()).hexdigest()[:16]
file_ext = Path(file_name).suffix if file_name else '.bin'
# Create temporary upload directory in user's home
if is_config_admin:
# Config admin: use their home directory
temp_dir = Path.home() / '.aisbf' / 'temp_uploads'
else:
# Database user: use their auth files directory
temp_dir = get_user_auth_files_dir(current_user_id) / 'temp_uploads'
temp_dir.mkdir(parents=True, exist_ok=True)
# Save chunk
chunk_path = temp_dir / f"{upload_id}.part{chunk_number}"
content = await file.read()
with open(chunk_path, 'wb') as f:
f.write(content)
# Check if all chunks are received
received_chunks = list(temp_dir.glob(f"{upload_id}.part*"))
if len(received_chunks) == total_chunks:
# Assemble final file
import uuid
stored_filename = f"{provider_key}_{file_type}_{uuid.uuid4().hex[:8]}{file_ext}"
if is_config_admin:
auth_files_dir = get_admin_auth_files_dir()
else:
auth_files_dir = get_user_auth_files_dir(current_user_id)
file_path = auth_files_dir / stored_filename
# Combine chunks
with open(file_path, 'wb') as outfile:
for i in range(1, total_chunks + 1):
chunk_path = temp_dir / f"{upload_id}.part{i}"
with open(chunk_path, 'rb') as infile:
outfile.write(infile.read())
chunk_path.unlink()
# Save metadata to database if not admin
if not is_config_admin:
db = DatabaseRegistry.get_config_database()
# CLI credentials for claude providers go directly into
# user_oauth2_credentials so the provider handler can read them
# without touching the filesystem at request time.
if file_type == 'cli_credentials':
try:
with open(file_path, 'r') as fh:
cli_creds_content = json.load(fh)
db.save_user_oauth2_credentials(
user_id=current_user_id,
provider_id=provider_key,
auth_type='claude_cli_credentials',
credentials={'credentials': cli_creds_content},
)
file_path.unlink(missing_ok=True)
logger.info(
f"Stored CLI credentials for user {current_user_id} "
f"provider {provider_key} in user_oauth2_credentials"
)
except Exception as exc:
logger.error(f"Failed to save CLI credentials to DB: {exc}")
else:
db.save_user_auth_file(
user_id=current_user_id,
provider_id=provider_key,
file_type=file_type,
original_filename=file_name,
stored_filename=stored_filename,
file_path=str(file_path),
file_size=total_size,
mime_type=file.content_type
)
else:
# Config admin: update providers.json with full path
try:
config_path = Path.home() / '.aisbf' / 'providers.json'
if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'providers.json'
with open(config_path) as f:
full_config = json.load(f)
# Navigate to the provider config
if 'providers' in full_config and isinstance(full_config['providers'], dict):
providers = full_config['providers']
else:
providers = full_config
# Update the file path in provider config
if provider_key in providers:
# Convert absolute path to ~/... format
relative_path = str(file_path).replace(str(Path.home()), '~')
# Update the correct location based on provider type
provider_type = providers[provider_key].get('type', '')
# For Kiro, update kiro_config.sqlite_db or kiro_config.creds_file
if provider_type == 'kiro' and file_type in ['sqlite_db', 'creds_file']:
if 'kiro_config' not in providers[provider_key]:
providers[provider_key]['kiro_config'] = {}
providers[provider_key]['kiro_config'][file_type] = relative_path
logger.info(f"Updated providers.json: {provider_key}.kiro_config.{file_type} = {relative_path}")
# For Claude, update claude_config.credentials_file
elif provider_type == 'claude' and file_type in ['credentials_file', 'claude_credentials']:
if 'claude_config' not in providers[provider_key]:
providers[provider_key]['claude_config'] = {}
providers[provider_key]['claude_config']['credentials_file'] = relative_path
logger.info(f"Updated providers.json: {provider_key}.claude_config.credentials_file = {relative_path}")
# For Kilo, update kilo_config.creds_file
elif provider_type in ['kilo', 'kilocode'] and file_type in ['credentials_file', 'creds_file']:
if 'kilo_config' not in providers[provider_key]:
providers[provider_key]['kilo_config'] = {}
providers[provider_key]['kilo_config']['creds_file'] = relative_path
logger.info(f"Updated providers.json: {provider_key}.kilo_config.creds_file = {relative_path}")
# For Qwen, update qwen_config.credentials_file
elif provider_type == 'qwen' and file_type in ['credentials_file']:
if 'qwen_config' not in providers[provider_key]:
providers[provider_key]['qwen_config'] = {}
providers[provider_key]['qwen_config']['credentials_file'] = relative_path
logger.info(f"Updated providers.json: {provider_key}.qwen_config.credentials_file = {relative_path}")
# For Codex, update codex_config.credentials_file
elif provider_type == 'codex' and file_type in ['credentials_file', 'creds_file']:
if 'codex_config' not in providers[provider_key]:
providers[provider_key]['codex_config'] = {}
providers[provider_key]['codex_config']['credentials_file'] = relative_path
logger.info(f"Updated providers.json: {provider_key}.codex_config.credentials_file = {relative_path}")
# For Claude CLI credentials – store path in claude_config.cli_credentials_file
elif provider_type == 'claude' and file_type == 'cli_credentials':
if 'claude_config' not in providers[provider_key]:
providers[provider_key]['claude_config'] = {}
providers[provider_key]['claude_config']['cli_credentials_file'] = relative_path
logger.info(f"Updated providers.json: {provider_key}.claude_config.cli_credentials_file = {relative_path}")
# Fallback: update top-level field
else:
providers[provider_key][file_type] = relative_path
logger.info(f"Updated providers.json: {provider_key}.{file_type} = {relative_path}")
# Save updated config
save_path = Path.home() / '.aisbf' / 'providers.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
except Exception as e:
logger.error(f"Failed to update providers.json: {e}")
logger.info(f"Upload complete: {file_path} ({total_size} bytes, {total_chunks} chunks)")
return JSONResponse({
"success": True,
"complete": True,
"message": "File uploaded successfully",
"file_path": str(file_path),
"stored_filename": stored_filename
})
return JSONResponse({
"success": True,
"complete": False,
"received_chunks": len(received_chunks),
"total_chunks": total_chunks
})
except Exception as e:
logger.error(f"Chunk upload error: {e}")
# Cleanup failed upload
try:
for chunk_path in temp_dir.glob(f"{upload_id}.part*"):
chunk_path.unlink()
except Exception:
pass
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.get("/dashboard/providers/{provider_name}/files")
async def dashboard_provider_files(request: Request, provider_name: str):
"""Get all authentication files for a global provider (admin only)"""
auth_check = require_admin(request)
if auth_check:
return auth_check
try:
auth_files_dir = get_admin_auth_files_dir()
files = []
for file_path in auth_files_dir.glob(f"{provider_name}_*"):
if file_path.is_file():
stat = file_path.stat()
files.append({
"filename": file_path.name,
"file_path": str(file_path),
"file_size": stat.st_size,
"modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat()
})
return JSONResponse({"files": files})
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
@router.get("/dashboard/providers/{provider_name}/files/{filename}/download")
async def dashboard_provider_file_download(
request: Request,
provider_name: str,
filename: str
):
"""Download an authentication file for a global provider (admin only)"""
auth_check = require_admin(request)
if auth_check:
return auth_check
try:
auth_files_dir = get_admin_auth_files_dir()
file_path = auth_files_dir / filename
if not file_path.exists() or not file_path.is_file():
return JSONResponse(status_code=404, content={"error": "File not found"})
# Security check: ensure file belongs to this provider
if not filename.startswith(f"{provider_name}_"):
return JSONResponse(status_code=403, content={"error": "Access denied"})
return FileResponse(
path=str(file_path),
filename=filename,
media_type='application/octet-stream'
)
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
@router.delete("/dashboard/providers/{provider_name}/files/{filename}")
async def dashboard_provider_file_delete(
request: Request,
provider_name: str,
filename: str
):
"""Delete an authentication file for a global provider (admin only)"""
auth_check = require_admin(request)
if auth_check:
return auth_check
try:
auth_files_dir = get_admin_auth_files_dir()
file_path = auth_files_dir / filename
if not file_path.exists():
return JSONResponse(status_code=404, content={"error": "File not found"})
# Security check: ensure file belongs to this provider
if not filename.startswith(f"{provider_name}_"):
return JSONResponse(status_code=403, content={"error": "Access denied"})
file_path.unlink()
return JSONResponse({"message": "File deleted successfully"})
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
# OAuth authentication check endpoints for providers
@router.get("/dashboard/providers/{provider_name}/auth/check")
async def dashboard_provider_auth_check(request: Request, provider_name: str):
"""Check OAuth authentication status for a provider"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
# Get user ID from session
current_user_id = request.session.get('user_id')
# Load provider configuration
provider_config = None
if current_user_id is None:
# Admin: check global config
provider_config = _config.providers.get(provider_name)
else:
# Regular user: get from user providers
from aisbf.database import DatabaseRegistry
db = DatabaseRegistry.get_config_database()
user_provider = db.get_user_provider(current_user_id, provider_name)
if user_provider:
provider_config = user_provider['config']
if not provider_config:
return JSONResponse(
status_code=404,
content={"authenticated": False, "error": f"Provider '{provider_name}' not found"}
)
# Handle both dict (user providers) and object (global providers)
if isinstance(provider_config, dict):
provider_type = provider_config.get('type')
else:
provider_type = provider_config.type
if provider_type == 'claude':
from aisbf.auth.claude import ClaudeAuth
# Handle dict vs object
if isinstance(provider_config, dict):
claude_config = provider_config.get('claude_config', {})
else:
claude_config = provider_config.claude_config or {}
if current_user_id is None:
# Admin user: load from file (ClaudeAuth saves back to file automatically)
auth = ClaudeAuth(credentials_file=claude_config.get('credentials_file', '~/.claude_credentials.json'))
else:
# Regular user: load from database; wire up a save-callback so any
# token refresh is persisted back to the database immediately.
db = DatabaseRegistry.get_config_database()
def _save_claude_to_db(creds):
try:
db.save_user_oauth2_credentials(
user_id=current_user_id,
provider_id=provider_name,
auth_type='claude_oauth2',
credentials=creds,
)
except Exception as _e:
logger.warning(f"Failed to save refreshed Claude credentials to database: {_e}")
auth = ClaudeAuth(
credentials_file=claude_config.get('credentials_file', '~/.claude_credentials.json'),
skip_initial_load=True,
save_callback=_save_claude_to_db,
)
try:
if db:
db_creds = db.get_user_oauth2_credentials(
user_id=current_user_id,
provider_id=provider_name,
auth_type='claude_oauth2'
)
if db_creds and db_creds.get('credentials'):
auth.tokens = db_creds['credentials'].get('tokens', {})
if auth.tokens and 'expires_at' not in auth.tokens and 'expires_in' in auth.tokens:
auth.tokens['expires_at'] = time.time() + auth.tokens.get('expires_in', 3600)
except Exception as e:
logger.warning(f"Failed to load Claude credentials from database: {e}")
if not auth.is_authenticated():
return JSONResponse({"authenticated": False})
# Auto-refresh if expired (or expiring within 5 minutes)
expires_at = auth.tokens.get('expires_at', 0)
if time.time() >= (expires_at - 300):
logger.info(f"Claude token expired/expiring for provider {provider_name}, attempting refresh")
refreshed = await auth.refresh_token()
if not refreshed:
logger.warning(f"Claude token refresh failed for provider {provider_name}")
return JSONResponse({"authenticated": False, "error": "Token expired and refresh failed. Please re-authenticate."})
logger.info(f"Claude token refreshed successfully for provider {provider_name}")
return JSONResponse({
"authenticated": True,
"expires_at": auth.tokens.get('expires_at', 0),
})
elif provider_type == 'kilocode':
from aisbf.auth.kilo import KiloOAuth2
# Handle dict vs object
if isinstance(provider_config, dict):
kilo_config = provider_config.get('kilo_config', {})
else:
kilo_config = provider_config.kilo_config or {}
if current_user_id is None:
# Admin user: load from file
auth = KiloOAuth2(credentials_file=kilo_config.get('credentials_file', '~/.kilo_credentials.json'))
else:
# Regular user: load from database
auth = KiloOAuth2(
credentials_file=kilo_config.get('credentials_file', '~/.kilo_credentials.json'),
skip_initial_load=True
)
# Load credentials from database
try:
db = DatabaseRegistry.get_config_database()
if db:
db_creds = db.get_user_oauth2_credentials(
user_id=current_user_id,
provider_id=provider_name,
auth_type='kilo_oauth2'
)
if db_creds and db_creds.get('credentials'):
auth.credentials = db_creds['credentials']
except Exception as e:
logger.warning(f"Failed to load Kilo credentials from database: {e}")
is_auth = auth.is_authenticated()
result = {"authenticated": is_auth}
if is_auth and auth.credentials:
expires = auth.credentials.get('expires', 0)
if expires:
result["expires_at"] = expires
return JSONResponse(result)
elif provider_type == 'qwen':
from aisbf.auth.qwen import QwenOAuth2
# Handle dict vs object
if isinstance(provider_config, dict):
qwen_config = provider_config.get('qwen_config', {})
else:
qwen_config = provider_config.qwen_config or {}
if current_user_id is None:
# Admin user: load from file
auth = QwenOAuth2(credentials_file=qwen_config.get('credentials_file', '~/.aisbf/qwen_credentials.json'))
else:
# Regular user: load from database with save_callback so refreshed tokens persist
db = DatabaseRegistry.get_config_database()
def _save_qwen_to_db(creds):
try:
if db:
db.save_user_oauth2_credentials(
user_id=current_user_id,
provider_id=provider_name,
auth_type='qwen_oauth2',
credentials=creds
)
except Exception as _e:
logger.warning(f"Failed to save refreshed Qwen credentials to database: {_e}")
auth = QwenOAuth2(
credentials_file=qwen_config.get('credentials_file', '~/.aisbf/qwen_credentials.json'),
skip_initial_load=True,
save_callback=_save_qwen_to_db
)
# Load credentials from database
try:
if db:
db_creds = db.get_user_oauth2_credentials(
user_id=current_user_id,
provider_id=provider_name,
auth_type='qwen_oauth2'
)
if db_creds and db_creds.get('credentials'):
auth.credentials = db_creds['credentials']
except Exception as e:
logger.warning(f"Failed to load Qwen credentials from database: {e}")
is_auth = auth.is_authenticated()
if not is_auth and auth.credentials and auth.credentials.get('refresh_token'):
refreshed = await auth.refresh_tokens()
if refreshed:
is_auth = True
else:
return JSONResponse({"authenticated": False, "error": "Token expired and refresh failed. Please re-authenticate."})
result = {"authenticated": is_auth}
if is_auth and auth.credentials:
expiry_date = auth.credentials.get('expiry_date', 0)
if expiry_date:
# Convert from milliseconds to seconds
result["expires_at"] = expiry_date / 1000
return JSONResponse(result)
elif provider_type == 'codex':
from aisbf.auth.codex import CodexOAuth2
# Handle dict vs object
if isinstance(provider_config, dict):
codex_config = provider_config.get('codex_config', {})
else:
codex_config = provider_config.codex_config or {}
if current_user_id is None:
# Admin user: load from file
auth = CodexOAuth2(credentials_file=codex_config.get('credentials_file', '~/.aisbf/codex_credentials.json'))
else:
# Regular user: load from database with save_callback so refreshed tokens persist
db = DatabaseRegistry.get_config_database()
def _save_codex_to_db(creds):
try:
if db:
db.save_user_oauth2_credentials(
user_id=current_user_id,
provider_id=provider_name,
auth_type='codex_oauth2',
credentials=creds
)
except Exception as _e:
logger.warning(f"Failed to save refreshed Codex credentials to database: {_e}")
auth = CodexOAuth2(
credentials_file=codex_config.get('credentials_file', '~/.aisbf/codex_credentials.json'),
skip_initial_load=True,
save_callback=_save_codex_to_db
)
# Load credentials from database
try:
if db:
db_creds = db.get_user_oauth2_credentials(
user_id=current_user_id,
provider_id=provider_name,
auth_type='codex_oauth2'
)
if db_creds and db_creds.get('credentials'):
auth.credentials = db_creds['credentials']
except Exception as e:
logger.warning(f"Failed to load Codex credentials from database: {e}")
# get_valid_token_with_refresh handles expiry check and refresh atomically
token = await auth.get_valid_token_with_refresh()
if not token:
if auth.credentials:
return JSONResponse({"authenticated": False, "error": "Token expired and refresh failed. Please re-authenticate."})
return JSONResponse({"authenticated": False})
result = {"authenticated": True}
if auth.credentials:
expires = auth.credentials.get('expires', 0)
if expires:
result["expires_at"] = expires
return JSONResponse(result)
else:
return JSONResponse(
status_code=400,
content={"authenticated": False, "error": f"Provider type '{provider_type}' does not support OAuth authentication checks"}
)
except Exception as e:
logger.error(f"Error checking auth for provider {provider_name}: {e}")
return JSONResponse(
status_code=500,
content={"authenticated": False, "error": str(e)}
)
# User-specific rotation management routes
@router.get("/dashboard/user/rotations", response_class=HTMLResponse)
async def dashboard_user_rotations(request: Request):
"""Redirect to unified rotations endpoint"""
return RedirectResponse(url=url_for(request, "/dashboard/rotations"), status_code=301)
@router.post("/dashboard/user/rotations")
async def dashboard_user_rotations_save(request: Request, config: str = Form(...)):
"""Redirect to unified rotations save endpoint"""
return await dashboard_rotations_save(request, config)
@router.delete("/dashboard/user/rotations/{rotation_name}")
async def dashboard_user_rotations_delete(request: Request, rotation_name: str):
"""Delete user-specific rotation configuration"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
if not user_id:
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
db = DatabaseRegistry.get_config_database()
try:
db.delete_user_rotation(user_id, rotation_name)
return JSONResponse({"message": "Rotation deleted successfully"})
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
# User-specific autoselect management routes
@router.get("/dashboard/user/autoselects", response_class=HTMLResponse)
async def dashboard_user_autoselects(request: Request):
"""Redirect to unified autoselect endpoint"""
return RedirectResponse(url=url_for(request, "/dashboard/autoselect"), status_code=301)
@router.post("/dashboard/user/autoselects")
async def dashboard_user_autoselects_save(request: Request, config: str = Form(...)):
"""Redirect to unified autoselect save endpoint"""
return await dashboard_autoselect_save(request, config)
@router.delete("/dashboard/user/autoselects/{autoselect_name}")
async def dashboard_user_autoselects_delete(request: Request, autoselect_name: str):
"""Delete user-specific autoselect configuration"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
if not user_id:
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
db = DatabaseRegistry.get_config_database()
try:
db.delete_user_autoselect(user_id, autoselect_name)
return JSONResponse({"message": "Autoselect deleted successfully"})
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
@router.post("/dashboard/user/reload-config")
async def dashboard_user_reload_config(request: Request):
"""Reload user configuration from database"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
if not user_id:
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
try:
global _user_handlers_cache
# Clear all cached handler instances for this user
cache_keys_to_remove = [
key for key in _user_handlers_cache.keys()
if key.endswith(f"_{user_id}")
]
for key in cache_keys_to_remove:
del _user_handlers_cache[key]
logger.info(f"Cleared {len(cache_keys_to_remove)} cached handler(s) for user {user_id}")
# Force create new handler instances to verify reload works
get_user_handler('request', user_id)
get_user_handler('rotation', user_id)
get_user_handler('autoselect', user_id)
logger.info(f"User {user_id} configuration reloaded successfully from database")
return JSONResponse({
"message": "Configuration reloaded successfully. All changes have been applied immediately.",
"handlers_cleared": len(cache_keys_to_remove)
})
except Exception as e:
logger.error(f"Error reloading user configuration: {e}")
return JSONResponse({"error": f"Failed to reload configuration: {str(e)}"}, status_code=500)
# User API token management routes
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from aisbf.mcp import mcp_server, MCPAuthLevel, load_mcp_config
import json, logging
from aisbf.database import DatabaseRegistry
router = APIRouter()
_server_config = None
_get_user_handler = None
def init(server_config_ref, get_user_handler_fn):
global _server_config, _get_user_handler
_server_config = server_config_ref
_get_user_handler = get_user_handler_fn
logger = logging.getLogger(__name__)
def get_mcp_auth_level(request: Request) -> int:
mcp_config = load_mcp_config()
if not mcp_config.get('enabled', False):
if _server_config and _server_config.get('auth_enabled', False):
auth_header = request.headers.get('Authorization', '')
if auth_header.startswith('Bearer '):
token = auth_header.replace('Bearer ', '')
if token in _server_config.get('auth_tokens', []):
return MCPAuthLevel.FULLCONFIG
return MCPAuthLevel.NONE
fullconfig_tokens = mcp_config.get('fullconfig_tokens', [])
autoselect_tokens = mcp_config.get('autoselect_tokens', [])
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return MCPAuthLevel.NONE
token = auth_header.replace('Bearer ', '')
if token in fullconfig_tokens:
return MCPAuthLevel.FULLCONFIG
if token in autoselect_tokens:
return MCPAuthLevel.AUTOSELECT
return MCPAuthLevel.NONE
@router.get("/mcp")
async def mcp_sse(request: Request):
auth_level = get_mcp_auth_level(request)
if auth_level == MCPAuthLevel.NONE:
return JSONResponse(status_code=401, content={"error": "Invalid or missing MCP authentication token"})
async def event_generator():
yield f"data: {json.dumps({'event': 'connected', 'auth_level': auth_level})}\n\n".encode('utf-8')
request_text = ""
try:
body = await request._receive()
if body and isinstance(body, bytes):
request_text = body.decode('utf-8')
elif body and isinstance(body, dict):
request_text = json.dumps(body)
except Exception as e:
logger.warning(f"Error reading MCP request body: {e}")
if not request_text:
request_text = request.query_params.get('request', '{}')
try:
mcp_request = json.loads(request_text) if request_text else {}
except json.JSONDecodeError:
yield f"data: {json.dumps({'error': 'Invalid JSON request'})}\n\n".encode('utf-8')
return
method = mcp_request.get('method', '')
request_id = mcp_request.get('id')
params = mcp_request.get('params', {})
if method == 'initialize':
response = {"jsonrpc": "2.0", "id": request_id, "result": {"protocolVersion": "2024-11-05", "capabilities": {"tools": {"listChanged": True}, "resources": {"subscribe": True, "listChanged": True}}, "serverInfo": {"name": "AISBF MCP Server", "version": "1.0.0"}}}
yield f"data: {json.dumps(response)}\n\n".encode('utf-8')
elif method == 'tools/list':
user_id = getattr(request.state, 'user_id', None)
tools = mcp_server.get_available_tools(auth_level, user_id)
yield f"data: {json.dumps({'jsonrpc': '2.0', 'id': request_id, 'result': {'tools': tools}})}\n\n".encode('utf-8')
elif method == 'tools/call':
tool_name = params.get('name')
arguments = params.get('arguments', {})
if not tool_name:
yield f"data: {json.dumps({'error': 'Tool name is required'})}\n\n".encode('utf-8')
return
try:
user_id = getattr(request.state, 'user_id', None)
result = await mcp_server.handle_tool_call(tool_name, arguments, auth_level, user_id)
yield f"data: {json.dumps({'jsonrpc': '2.0', 'id': request_id, 'result': result})}\n\n".encode('utf-8')
except Exception as e:
yield f"data: {json.dumps({'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32603, 'message': str(e)}})}\n\n".encode('utf-8')
yield f"data: {json.dumps({'event': 'done'})}\n\n".encode('utf-8')
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.post("/mcp")
async def mcp_post(request: Request):
auth_level = get_mcp_auth_level(request)
if auth_level == MCPAuthLevel.NONE:
return JSONResponse(status_code=401, content={"error": "Invalid or missing MCP authentication token"})
try:
body = await request.body()
mcp_request = json.loads(body.decode('utf-8')) if body else {}
except json.JSONDecodeError:
return JSONResponse(status_code=400, content={"error": "Invalid JSON request body"})
except Exception:
return JSONResponse(status_code=400, content={"error": "Invalid request body"})
method = mcp_request.get('method', '')
request_id = mcp_request.get('id')
params = mcp_request.get('params', {})
if method == 'initialize':
return {"jsonrpc": "2.0", "id": request_id, "result": {"protocolVersion": "2024-11-05", "capabilities": {"tools": {"listChanged": True}, "resources": {"subscribe": True, "listChanged": True}}, "serverInfo": {"name": "AISBF MCP Server", "version": "1.0.0"}}}
elif method == 'tools/list':
user_id = getattr(request.state, 'user_id', None)
tools = mcp_server.get_available_tools(auth_level, user_id)
return {"jsonrpc": "2.0", "id": request_id, "result": {"tools": tools}}
elif method == 'tools/call':
tool_name = params.get('name')
arguments = params.get('arguments', {})
if not tool_name:
return JSONResponse(status_code=400, content={"error": "Tool name is required"})
try:
user_id = getattr(request.state, 'user_id', None)
result = await mcp_server.handle_tool_call(tool_name, arguments, auth_level, user_id)
return {"jsonrpc": "2.0", "id": request_id, "result": result}
except Exception as e:
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32603, "message": str(e)}}
return JSONResponse(status_code=400, content={"error": "Unknown MCP method"})
@router.get("/mcp/u/{username}/tools")
async def mcp_user_list_tools(request: Request, username: str):
user_id = getattr(request.state, 'user_id', None)
is_admin = getattr(request.state, 'is_admin', False)
is_global_token = getattr(request.state, 'is_global_token', False)
if not user_id:
return JSONResponse(status_code=401, content={"error": "Authentication required"})
if not is_global_token and not is_admin:
db = DatabaseRegistry.get_config_database()
authenticated_user = db.get_user_by_id(user_id)
if authenticated_user and authenticated_user['username'] != username:
return JSONResponse(status_code=403, content={"error": "Access denied. Username in URL must match authenticated user."})
tools = mcp_server.get_available_tools(MCPAuthLevel.USER, user_id)
return {"tools": tools}
@router.post("/mcp/u/{username}/tools/call")
async def mcp_user_call_tool(request: Request, username: str):
user_id = getattr(request.state, 'user_id', None)
is_admin = getattr(request.state, 'is_admin', False)
is_global_token = getattr(request.state, 'is_global_token', False)
if not user_id:
return JSONResponse(status_code=401, content={"error": "Authentication required"})
if not is_global_token and not is_admin:
db = DatabaseRegistry.get_config_database()
authenticated_user = db.get_user_by_id(user_id)
if authenticated_user and authenticated_user['username'] != username:
return JSONResponse(status_code=403, content={"error": "Access denied. Username in URL must match authenticated user."})
try:
body = await request.body()
body_data = json.loads(body.decode('utf-8')) if body else {}
except Exception:
return JSONResponse(status_code=400, content={"error": "Invalid JSON request body"})
tool_name = body_data.get('name')
arguments = body_data.get('arguments', {})
if not tool_name:
return JSONResponse(status_code=400, content={"error": "Tool name is required"})
try:
result = await mcp_server.handle_tool_call(tool_name, arguments, MCPAuthLevel.USER, user_id)
return {"result": result}
except Exception as e:
logger.error(f"Error calling MCP tool: {e}")
return JSONResponse(status_code=500, content={"error": str(e)})
@router.get("/mcp/tools")
async def mcp_list_tools(request: Request):
auth_level = get_mcp_auth_level(request)
if auth_level == MCPAuthLevel.NONE:
return JSONResponse(status_code=401, content={"error": "Invalid or missing MCP authentication token"})
user_id = getattr(request.state, 'user_id', None)
tools = mcp_server.get_available_tools(auth_level, user_id)
return {"tools": tools}
@router.post("/mcp/tools/call")
async def mcp_call_tool(request: Request):
auth_level = get_mcp_auth_level(request)
if auth_level == MCPAuthLevel.NONE:
return JSONResponse(status_code=401, content={"error": "Invalid or missing MCP authentication token"})
try:
body = await request.body()
body_data = json.loads(body.decode('utf-8')) if body else {}
except Exception:
return JSONResponse(status_code=400, content={"error": "Invalid JSON request body"})
tool_name = body_data.get('name')
arguments = body_data.get('arguments', {})
if not tool_name:
return JSONResponse(status_code=400, content={"error": "Tool name is required"})
try:
user_id = getattr(request.state, 'user_id', None)
result = await mcp_server.handle_tool_call(tool_name, arguments, auth_level, user_id)
return {"result": result}
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
from typing import Optional
import logging, time
from aisbf.models import ChatCompletionRequest
from aisbf.database import DatabaseRegistry
from aisbf.app.model_cache import get_provider_models
router = APIRouter()
_config = None
_get_user_handler = None
def init(config, get_user_handler_fn):
global _config, _get_user_handler
_config = config
_get_user_handler = get_user_handler_fn
logger = logging.getLogger(__name__)
def parse_provider_from_model(model: str) -> tuple[str, str]:
if '/' in model:
parts = model.split('/', 1)
return parts[0], parts[1]
return None, model
@router.get("/api/u/{username}/models")
async def user_list_models(request: Request, username: str):
user_id = getattr(request.state, 'user_id', None)
is_admin = getattr(request.state, 'is_admin', False)
is_global_token = getattr(request.state, 'is_global_token', False)
if not is_global_token and not is_admin and user_id:
db = DatabaseRegistry.get_config_database()
authenticated_user = db.get_user_by_id(user_id)
if authenticated_user and authenticated_user['username'] != username:
return JSONResponse(status_code=403, content={"error": "Access denied. Username in URL must match authenticated user."})
if is_global_token or is_admin:
all_models = []
for provider_id, provider_config in _config.providers.items():
try:
provider_models = await get_provider_models(provider_id, provider_config)
all_models.extend(provider_models)
except Exception as e:
logger.warning(f"Error listing models for provider {provider_id}: {e}")
for rotation_id, rotation_config in _config.rotations.items():
try:
all_models.append({'id': f"rotation/{rotation_id}", 'object': 'model', 'created': int(time.time()), 'owned_by': 'aisbf-rotation', 'type': 'rotation', 'rotation_id': rotation_id, 'model_name': rotation_config.model_name, 'source': 'global'})
except Exception as e:
logger.warning(f"Error listing rotation {rotation_id}: {e}")
for autoselect_id, autoselect_config in _config.autoselect.items():
try:
all_models.append({'id': f"autoselect/{autoselect_id}", 'object': 'model', 'created': int(time.time()), 'owned_by': 'aisbf-autoselect', 'type': 'autoselect', 'autoselect_id': autoselect_id, 'model_name': autoselect_config.model_name, 'description': autoselect_config.description, 'source': 'global'})
except Exception as e:
logger.warning(f"Error listing autoselect {autoselect_id}: {e}")
if user_id and not is_global_token:
handler = _get_user_handler('request', user_id)
for provider_id, provider_config in handler.user_providers.items():
try:
if hasattr(provider_config, 'models') and provider_config.models:
for model in provider_config.models:
all_models.append({'id': f"{provider_id}/{model.name}", 'object': 'model', 'created': int(time.time()), 'owned_by': provider_id, 'provider': provider_id, 'type': 'user_provider', 'model_name': model.name, 'source': 'user_config'})
except Exception as e:
logger.warning(f"Error listing models for user provider {provider_id}: {e}")
rotation_handler = _get_user_handler('rotation', user_id)
for rotation_id in rotation_handler.rotations:
try:
all_models.append({'id': f"rotation/{rotation_id}", 'object': 'model', 'created': int(time.time()), 'owned_by': 'aisbf-rotation', 'type': 'rotation', 'rotation_id': rotation_id, 'source': 'user_config'})
except Exception as e:
logger.warning(f"Error listing user rotation {rotation_id}: {e}")
autoselect_handler = _get_user_handler('autoselect', user_id)
for autoselect_id in autoselect_handler.autoselects:
try:
all_models.append({'id': f"autoselect/{autoselect_id}", 'object': 'model', 'created': int(time.time()), 'owned_by': 'aisbf-autoselect', 'type': 'autoselect', 'autoselect_id': autoselect_id, 'source': 'user_config'})
except Exception as e:
logger.warning(f"Error listing user autoselect {autoselect_id}: {e}")
return {"object": "list", "data": all_models}
if not user_id:
return JSONResponse(status_code=401, content={"error": "Authentication required. Use a valid API token."})
all_models = []
handler = _get_user_handler('request', user_id)
for provider_id, provider_config in handler.user_providers.items():
try:
if hasattr(provider_config, 'models') and provider_config.models:
for model in provider_config.models:
all_models.append({'id': f"{provider_id}/{model.name}", 'object': 'model', 'created': int(time.time()), 'owned_by': provider_id, 'provider': provider_id, 'type': 'user_provider', 'model_name': model.name, 'context_size': getattr(model, 'context_size', None), 'capabilities': getattr(model, 'capabilities', []), 'description': getattr(model, 'description', None), 'source': 'user_config'})
except Exception as e:
logger.warning(f"Error listing models for user provider {provider_id}: {e}")
rotation_handler = _get_user_handler('rotation', user_id)
for rotation_id, rotation_config in rotation_handler.rotations.items():
try:
all_models.append({'id': f"rotation/{rotation_id}", 'object': 'model', 'created': int(time.time()), 'owned_by': 'aisbf-rotation', 'type': 'rotation', 'rotation_id': rotation_id, 'model_name': rotation_config.get('model_name', rotation_id), 'capabilities': rotation_config.get('capabilities', []), 'source': 'user_config'})
except Exception as e:
logger.warning(f"Error listing user rotation {rotation_id}: {e}")
autoselect_handler = _get_user_handler('autoselect', user_id)
for autoselect_id, autoselect_config in autoselect_handler.autoselects.items():
try:
all_models.append({'id': f"autoselect/{autoselect_id}", 'object': 'model', 'created': int(time.time()), 'owned_by': 'aisbf-autoselect', 'type': 'autoselect', 'autoselect_id': autoselect_id, 'model_name': autoselect_config.get('model_name', autoselect_id), 'description': autoselect_config.get('description'), 'capabilities': autoselect_config.get('capabilities', []), 'source': 'user_config'})
except Exception as e:
logger.warning(f"Error listing user autoselect {autoselect_id}: {e}")
return {"object": "list", "data": all_models}
@router.get("/api/u/{username}/providers")
async def user_list_providers(request: Request, username: str):
user_id = getattr(request.state, 'user_id', None)
is_admin = getattr(request.state, 'is_admin', False)
is_global_token = getattr(request.state, 'is_global_token', False)
if not is_global_token and not is_admin and user_id:
db = DatabaseRegistry.get_config_database()
authenticated_user = db.get_user_by_id(user_id)
if authenticated_user and authenticated_user['username'] != username:
return JSONResponse(status_code=403, content={"error": "Access denied. Username in URL must match authenticated user."})
if is_global_token or is_admin:
providers_info = {}
for provider_id, provider_config in _config.providers.items():
try:
config_dict = provider_config.model_dump() if hasattr(provider_config, 'model_dump') else vars(provider_config) if hasattr(provider_config, '__dict__') else {}
safe_config = {k: v for k, v in config_dict.items() if k not in ['api_key', 'password', 'secret', 'token']}
providers_info[provider_id] = {'name': getattr(provider_config, 'name', provider_id), 'type': getattr(provider_config, 'type', 'unknown'), 'endpoint': getattr(provider_config, 'endpoint', None), 'models_count': len(getattr(provider_config, 'models', [])), 'config': safe_config, 'source': 'global'}
except Exception as e:
logger.warning(f"Error listing global provider {provider_id}: {e}")
if user_id and not is_global_token:
handler = _get_user_handler('request', user_id)
for provider_id, provider_config in handler.user_providers.items():
try:
config_dict = provider_config.model_dump() if hasattr(provider_config, 'model_dump') else vars(provider_config) if hasattr(provider_config, '__dict__') else {}
safe_config = {k: v for k, v in config_dict.items() if k not in ['api_key', 'password', 'secret', 'token']}
providers_info[provider_id] = {'name': getattr(provider_config, 'name', provider_id), 'type': getattr(provider_config, 'type', 'unknown'), 'endpoint': getattr(provider_config, 'endpoint', None), 'models_count': len(getattr(provider_config, 'models', [])), 'config': safe_config, 'source': 'user_config'}
except Exception as e:
logger.warning(f"Error listing user provider {provider_id}: {e}")
return {"providers": providers_info}
if not user_id:
return JSONResponse(status_code=401, content={"error": "Authentication required. Use a valid API token."})
handler = _get_user_handler('request', user_id)
providers_info = {}
for provider_id, provider_config in handler.user_providers.items():
try:
config_dict = provider_config.model_dump() if hasattr(provider_config, 'model_dump') else vars(provider_config) if hasattr(provider_config, '__dict__') else {}
safe_config = {k: v for k, v in config_dict.items() if k not in ['api_key', 'password', 'secret', 'token']}
providers_info[provider_id] = {'name': getattr(provider_config, 'name', provider_id), 'type': getattr(provider_config, 'type', 'unknown'), 'endpoint': getattr(provider_config, 'endpoint', None), 'models_count': len(getattr(provider_config, 'models', [])), 'config': safe_config}
except Exception as e:
logger.warning(f"Error listing user provider {provider_id}: {e}")
return {"providers": providers_info}
@router.get("/api/u/{username}/rotations")
async def user_list_rotations(request: Request, username: str):
user_id = getattr(request.state, 'user_id', None)
is_admin = getattr(request.state, 'is_admin', False)
is_global_token = getattr(request.state, 'is_global_token', False)
if not is_global_token and not is_admin and user_id:
db = DatabaseRegistry.get_config_database()
authenticated_user = db.get_user_by_id(user_id)
if authenticated_user and authenticated_user['username'] != username:
return JSONResponse(status_code=403, content={"error": "Access denied. Username in URL must match authenticated user."})
if is_global_token or is_admin:
rotations_info = {}
for rotation_id, rotation_config in _config.rotations.items():
try:
rotations_info[rotation_id] = {"model_name": rotation_config.model_name, "providers": rotation_config.providers, "source": "global"}
except Exception as e:
logger.warning(f"Error listing global rotation {rotation_id}: {e}")
if user_id and not is_global_token:
handler = _get_user_handler('rotation', user_id)
for rotation_id, rotation_config in handler.rotations.items():
try:
rotations_info[rotation_id] = {"model_name": rotation_config.get('model_name', rotation_id), "providers": rotation_config.get('providers', []), "source": "user_config"}
except Exception as e:
logger.warning(f"Error listing user rotation {rotation_id}: {e}")
return {"rotations": rotations_info}
if not user_id:
return JSONResponse(status_code=401, content={"error": "Authentication required. Use a valid API token."})
handler = _get_user_handler('rotation', user_id)
rotations_info = {}
for rotation_id, rotation_config in handler.rotations.items():
try:
rotations_info[rotation_id] = {"model_name": rotation_config.get('model_name', rotation_id), "providers": rotation_config.get('providers', [])}
except Exception as e:
logger.warning(f"Error listing user rotation {rotation_id}: {e}")
return {"rotations": rotations_info}
@router.get("/api/u/{username}/autoselects")
async def user_list_autoselects(request: Request, username: str):
user_id = getattr(request.state, 'user_id', None)
is_admin = getattr(request.state, 'is_admin', False)
is_global_token = getattr(request.state, 'is_global_token', False)
if not is_global_token and not is_admin and user_id:
db = DatabaseRegistry.get_config_database()
authenticated_user = db.get_user_by_id(user_id)
if authenticated_user and authenticated_user['username'] != username:
return JSONResponse(status_code=403, content={"error": "Access denied. Username in URL must match authenticated user."})
if is_global_token or is_admin:
autoselects_info = {}
for autoselect_id, autoselect_config in _config.autoselect.items():
try:
autoselects_info[autoselect_id] = {"model_name": autoselect_config.model_name, "description": autoselect_config.description, "fallback": autoselect_config.fallback, "available_models": [{"model_id": m.model_id, "description": m.description} for m in autoselect_config.available_models], "source": "global"}
except Exception as e:
logger.warning(f"Error listing global autoselect {autoselect_id}: {e}")
if user_id and not is_global_token:
handler = _get_user_handler('autoselect', user_id)
for autoselect_id, autoselect_config in handler.autoselects.items():
try:
autoselects_info[autoselect_id] = {"model_name": autoselect_config.get('model_name', autoselect_id), "description": autoselect_config.get('description', ''), "fallback": autoselect_config.get('fallback', ''), "available_models": autoselect_config.get('available_models', []), "source": "user_config"}
except Exception as e:
logger.warning(f"Error listing user autoselect {autoselect_id}: {e}")
return {"autoselects": autoselects_info}
if not user_id:
return JSONResponse(status_code=401, content={"error": "Authentication required. Use a valid API token."})
handler = _get_user_handler('autoselect', user_id)
autoselects_info = {}
for autoselect_id, autoselect_config in handler.autoselects.items():
try:
autoselects_info[autoselect_id] = {"model_name": autoselect_config.get('model_name', autoselect_id), "description": autoselect_config.get('description', ''), "fallback": autoselect_config.get('fallback', ''), "available_models": autoselect_config.get('available_models', [])}
except Exception as e:
logger.warning(f"Error listing user autoselect {autoselect_id}: {e}")
return {"autoselects": autoselects_info}
@router.post("/api/u/{username}/chat/completions")
async def user_chat_completions(request: Request, username: str, body: ChatCompletionRequest):
user_id = getattr(request.state, 'user_id', None)
is_admin = getattr(request.state, 'is_admin', False)
is_global_token = getattr(request.state, 'is_global_token', False)
if not is_global_token and not is_admin and user_id:
db = DatabaseRegistry.get_config_database()
authenticated_user = db.get_user_by_id(user_id)
if authenticated_user and authenticated_user['username'] != username:
raise HTTPException(status_code=403, detail="Access denied. Username in URL must match authenticated user.")
provider_id, actual_model = parse_provider_from_model(body.model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', 'autoselect/name', 'user-provider/model', 'user-rotation/name', or 'user-autoselect/name'")
body_dict = body.model_dump()
if provider_id == "user-autoselect":
handler = _get_user_handler('autoselect', user_id)
if actual_model not in handler.user_autoselects:
raise HTTPException(status_code=400, detail=f"User autoselect '{actual_model}' not found. Available: {list(handler.user_autoselects.keys())}")
body_dict['model'] = actual_model
if body.stream:
return await handler.handle_autoselect_streaming_request(actual_model, body_dict)
else:
token_id = getattr(request.state, 'token_id', None)
return await handler.handle_autoselect_request(actual_model, body_dict, user_id, token_id)
if provider_id == "user-rotation":
handler = _get_user_handler('rotation', user_id)
if actual_model not in handler.rotations:
raise HTTPException(status_code=400, detail=f"User rotation '{actual_model}' not found. Available: {list(handler.rotations.keys())}")
body_dict['model'] = actual_model
token_id = getattr(request.state, 'token_id', None)
return await handler.handle_rotation_request(actual_model, body_dict, user_id, token_id)
if provider_id == "user-provider":
handler = _get_user_handler('request', user_id)
if actual_model not in handler.user_providers:
raise HTTPException(status_code=400, detail=f"User provider '{actual_model}' not found. Available: {list(handler.user_providers.keys())}")
body_dict['model'] = actual_model
if body.stream:
return await handler.handle_streaming_chat_completion(request, actual_model, body_dict)
else:
return await handler.handle_chat_completion(request, actual_model, body_dict)
if is_global_token or is_admin:
if provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
handler = _get_user_handler('autoselect', None)
body_dict['model'] = actual_model
if body.stream:
return await handler.handle_autoselect_streaming_request(actual_model, body_dict)
else:
token_id = getattr(request.state, 'token_id', None)
return await handler.handle_autoselect_request(actual_model, body_dict, user_id, token_id)
if provider_id == "rotation":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
handler = _get_user_handler('rotation', None)
body_dict['model'] = actual_model
token_id = getattr(request.state, 'token_id', None)
return await handler.handle_rotation_request(actual_model, body_dict, user_id, token_id)
if provider_id in _config.providers:
body_dict['model'] = actual_model
handler = _get_user_handler('request', None)
if body.stream:
return await handler.handle_streaming_chat_completion(request, provider_id, body_dict)
else:
return await handler.handle_chat_completion(request, provider_id, body_dict)
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'. Global configurations are only available to admin users.")
@router.get("/api/u/{username}/rotations/models")
async def user_list_rotation_models(request: Request, username: str):
return await user_list_config_models(request, username, "rotations")
@router.get("/api/u/{username}/autoselections/models")
async def user_list_autoselection_models(request: Request, username: str):
return await user_list_config_models(request, username, "autoselects")
@router.get("/api/u/{username}/{config_type}/models")
async def user_list_config_models(request: Request, username: str, config_type: str):
user_id = getattr(request.state, 'user_id', None)
is_admin = getattr(request.state, 'is_admin', False)
is_global_token = getattr(request.state, 'is_global_token', False)
if not is_global_token and not is_admin and user_id:
db = DatabaseRegistry.get_config_database()
authenticated_user = db.get_user_by_id(user_id)
if authenticated_user and authenticated_user['username'] != username:
return JSONResponse(status_code=403, content={"error": "Access denied. Username in URL must match authenticated user."})
if not user_id:
return JSONResponse(status_code=401, content={"error": "Authentication required. Use a valid API token."})
all_models = []
if config_type == "providers":
handler = _get_user_handler('request', user_id)
for provider_id, provider_config in handler.user_providers.items():
try:
if hasattr(provider_config, 'models') and provider_config.models:
for model in provider_config.models:
all_models.append({"id": f"user-provider/{provider_id}/{model.name}", "name": model.name, "object": "model", "created": int(time.time()), "owned_by": provider_id, "provider_id": provider_id, "type": "user_provider"})
except Exception as e:
logger.warning(f"Error listing models for user provider {provider_id}: {e}")
elif config_type == "rotations":
handler = _get_user_handler('rotation', user_id)
for rotation_id, rotation_config in handler.rotations.items():
try:
for provider in rotation_config.get('providers', []):
for model in provider.get('models', []):
all_models.append({"id": f"rotation/{rotation_id}/{model.get('name', '')}", "name": rotation_id, "object": "model", "created": int(time.time()), "owned_by": provider.get('provider_id', ''), "rotation_id": rotation_id, "actual_model": model.get('name', ''), "provider_id": provider.get('provider_id', ''), "weight": model.get('weight', 1), "type": "user_rotation"})
except Exception as e:
logger.warning(f"Error listing user rotation {rotation_id}: {e}")
elif config_type == "autoselects":
handler = _get_user_handler('autoselect', user_id)
for autoselect_id, autoselect_config in handler.autoselects.items():
try:
for model_info in autoselect_config.get('available_models', []):
all_models.append({"id": f"user-autoselect/{autoselect_id}/{model_info.get('model_id', '')}", "name": autoselect_id, "object": "model", "created": int(time.time()), "owned_by": "user-autoselect", "autoselect_id": autoselect_id, "description": model_info.get('description', ''), "type": "user_autoselect"})
except Exception as e:
logger.warning(f"Error listing user autoselect {autoselect_id}: {e}")
else:
raise HTTPException(status_code=400, detail="Invalid config type. Use 'providers', 'rotations', or 'autoselects'")
return {"data": all_models}
@router.get("/api/u/{username}/{user_provider_id}/models")
async def user_list_provider_models_by_username(request: Request, username: str, user_provider_id: str):
"""List models for a specific user provider."""
import time as _time
from aisbf.app.model_cache import fetch_provider_models
db = DatabaseRegistry.get_config_database()
target_user = db.get_user_by_username(username)
if not target_user:
return JSONResponse(status_code=404, content={"error": f"User '{username}' not found"})
target_user_id = target_user['id']
is_admin = getattr(request.state, 'is_admin', False)
is_global_token = getattr(request.state, 'is_global_token', False)
authenticated_user_id = getattr(request.state, 'user_id', None)
if not (is_admin or is_global_token or authenticated_user_id == target_user_id):
return JSONResponse(status_code=403, content={"error": "Permission denied"})
handler = _get_user_handler('request', target_user_id)
if user_provider_id not in handler.user_providers:
return JSONResponse(status_code=404, content={"error": f"User provider '{user_provider_id}' not found"})
provider_config = handler.user_providers[user_provider_id]
all_models = []
try:
if hasattr(provider_config, 'models') and provider_config.models:
for model in provider_config.models:
all_models.append({"id": f"{user_provider_id}/{model.name}", "name": model.name,
"object": "model", "created": int(_time.time()),
"owned_by": user_provider_id, "type": "user_provider"})
else:
models = await fetch_provider_models(user_provider_id, _config, user_id=target_user_id)
for model in models:
all_models.append({"id": f"{user_provider_id}/{model.get('id', model.get('name', ''))}",
"name": model.get('name', ''), "object": "model",
"created": int(_time.time()), "owned_by": user_provider_id,
"type": "user_provider"})
except Exception as e:
logging.getLogger(__name__).warning(f"Error listing models for user provider {user_provider_id}: {e}")
return {"data": all_models}
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" ...@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "aisbf" name = "aisbf"
version = "0.99.64" version = "0.99.65"
description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations" description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations"
readme = "README.md" readme = "README.md"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
...@@ -50,6 +50,9 @@ Documentation = "https://git.nexlab.net/nexlab/aisbf.git" ...@@ -50,6 +50,9 @@ Documentation = "https://git.nexlab.net/nexlab/aisbf.git"
[tool.setuptools] [tool.setuptools]
packages = [ packages = [
"aisbf", "aisbf",
"aisbf.app",
"aisbf.routes",
"aisbf.routes.dashboard",
"aisbf.auth", "aisbf.auth",
"aisbf.providers", "aisbf.providers",
"aisbf.providers.kiro", "aisbf.providers.kiro",
......
...@@ -106,7 +106,7 @@ class InstallCommand(_install): ...@@ -106,7 +106,7 @@ class InstallCommand(_install):
setup( setup(
name="aisbf", name="aisbf",
version="0.99.64", version="0.99.65",
author="AISBF Contributors", author="AISBF Contributors",
author_email="stefy@nexlab.net", author_email="stefy@nexlab.net",
description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations", description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations",
...@@ -172,7 +172,7 @@ setup( ...@@ -172,7 +172,7 @@ setup(
'aisbf/batching.py', 'aisbf/batching.py',
'aisbf/cache.py', 'aisbf/cache.py',
'aisbf/classifier.py', 'aisbf/classifier.py',
'aisbf/cli_mode.py', 'aisbf/providers/claude_cli.py',
'aisbf/cost_extractor.py', 'aisbf/cost_extractor.py',
'aisbf/streaming_optimization.py', 'aisbf/streaming_optimization.py',
'aisbf/analytics.py', 'aisbf/analytics.py',
...@@ -203,6 +203,31 @@ setup( ...@@ -203,6 +203,31 @@ setup(
'aisbf/providers/kiro/parsers.py', 'aisbf/providers/kiro/parsers.py',
'aisbf/providers/kiro/utils.py', 'aisbf/providers/kiro/utils.py',
]), ]),
# aisbf.app subpackage
('share/aisbf/aisbf/app', [
'aisbf/app/__init__.py',
'aisbf/app/startup.py',
'aisbf/app/templates.py',
'aisbf/app/model_cache.py',
'aisbf/app/middleware.py',
]),
# aisbf.routes subpackage
('share/aisbf/aisbf/routes', [
'aisbf/routes/__init__.py',
'aisbf/routes/auth.py',
'aisbf/routes/api.py',
'aisbf/routes/mcp.py',
'aisbf/routes/user_api.py',
]),
# aisbf.routes.dashboard subpackage
('share/aisbf/aisbf/routes/dashboard', [
'aisbf/routes/dashboard/__init__.py',
'aisbf/routes/dashboard/providers.py',
'aisbf/routes/dashboard/settings.py',
'aisbf/routes/dashboard/admin.py',
'aisbf/routes/dashboard/payments.py',
'aisbf/routes/dashboard/provider_auth.py',
]),
# aisbf.auth subpackage # aisbf.auth subpackage
('share/aisbf/aisbf/auth', [ ('share/aisbf/aisbf/auth', [
'aisbf/auth/__init__.py', 'aisbf/auth/__init__.py',
......
...@@ -799,7 +799,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -799,7 +799,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if request.session.impersonating_as %}
<div style="background: #7c3aed; color: #fff; text-align: center; padding: 8px 16px; font-size: 14px;">
<strong>⚠ Impersonating:</strong> {{ request.session.username }}
&nbsp;&nbsp;
<a href="{{ url_for(request, '/dashboard/logout') }}" style="color: #fff; text-decoration: underline;">Stop impersonating (return to admin)</a>
</div>
{% endif %}
{% if request.session.logged_in and show_upgrade_banner %} {% if request.session.logged_in and show_upgrade_banner %}
<div class="upgrade-banner"> <div class="upgrade-banner">
<div class="container"> <div class="container">
...@@ -1353,6 +1361,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -1353,6 +1361,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
] }); ] });
}); });
}; };
window.showConfirm = function(message, title, confirmLabel, confirmCls) {
return new Promise(res => {
_resolve = res;
open({ title: title||'Confirm', message, icon:'❓', iconClass:'',
buttons: [
{label:'Cancel', value:false, cls:'btn-secondary'},
{label: confirmLabel||'OK', value:true, cls:'btn ' + (confirmCls||'')}
] });
});
};
window.showPrompt = function(message, defaultValue, placeholder, title) { window.showPrompt = function(message, defaultValue, placeholder, title) {
return new Promise(res => { return new Promise(res => {
_resolve = v => res(v === null ? null : inputEl.value || null); _resolve = v => res(v === null ? null : inputEl.value || null);
......
...@@ -194,6 +194,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -194,6 +194,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<button onclick="toggleUserStatus({{ user.id }}, {{ user.is_active|lower }})" class="btn btn-warning" style="padding: 5px 10px; font-size: 12px; margin: 0;"> <button onclick="toggleUserStatus({{ user.id }}, {{ user.is_active|lower }})" class="btn btn-warning" style="padding: 5px 10px; font-size: 12px; margin: 0;">
{% if user.is_active %}Disable{% else %}Enable{% endif %} {% if user.is_active %}Disable{% else %}Enable{% endif %}
</button> </button>
<button onclick="impersonateUser({{ user.id }}, '{{ user.username }}')" class="btn" style="padding: 5px 10px; font-size: 12px; margin: 0; background: #7c3aed;">Impersonate</button>
<button onclick="deleteUser({{ user.id }}, '{{ user.username }}')" class="btn btn-danger" style="padding: 5px 10px; font-size: 12px; margin: 0;" data-i18n="users_page.delete_btn">Delete</button> <button onclick="deleteUser({{ user.id }}, '{{ user.username }}')" class="btn btn-danger" style="padding: 5px 10px; font-size: 12px; margin: 0;" data-i18n="users_page.delete_btn">Delete</button>
</div> </div>
</td> </td>
...@@ -799,6 +800,24 @@ async function deleteUser(userId, username) { ...@@ -799,6 +800,24 @@ async function deleteUser(userId, username) {
} }
} }
async function impersonateUser(userId, username) {
const ok = await showConfirm('Impersonate "' + username + '"? You will act as this user until you log out, which will return you to the admin session.', 'Impersonate User', 'Impersonate', 'btn-warning');
if (!ok) return;
fetch(baseUrl + userId + '/impersonate', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
window.location.href = data.redirect;
} else {
showAlert(data.error || 'Failed to impersonate user', 'Error', '❌', 'danger');
}
})
.catch(error => showAlert('Error: ' + error, 'Error', '❌', 'danger'));
}
function updateUserTier(userId, tierId) { function updateUserTier(userId, tierId) {
fetch(baseUrl + userId + '/tier', { fetch(baseUrl + userId + '/tier', {
method: 'POST', method: 'POST',
......
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