Commit f997a0fb authored by Your Name's avatar Your Name

Fix fix fix...

parent ceafa183
# AISBF CLI Tools Integration TODO
# TODO
**Date**: 2026-03-29
**Context**: Integration and verification of various AI CLI tools as AISBF providers
**Goal**: Expand AISBF's provider ecosystem by supporting popular CLI tools and verifying compatibility with existing services
## Planned Features
---
## 🔥 HIGH PRIORITY (Implement Soon)
### 1. Gemini CLI Integration
**Estimated Effort**: 2-3 days
**Expected Benefit**: Direct access to Google's Gemini models via official CLI
**ROI**: ⭐⭐⭐⭐ High
**Description**: Integrate Google's official Gemini CLI tool as a provider type in AISBF, allowing users to leverage their Gemini CLI credentials and configurations.
**Tasks**:
- [ ] Research Gemini CLI authentication and API structure
- [ ] Create `GeminiCLIProviderHandler` class in `aisbf/providers.py`
- [ ] Implement CLI command execution and response parsing
- [ ] Add configuration schema to `config/providers.json`
- [ ] Test with various Gemini models (Flash, Pro, Ultra)
- [ ] Add streaming support
- [ ] Update documentation with setup instructions
- [ ] Add dashboard UI support for Gemini CLI configuration
**Configuration Example**:
```json
{
"gemini-cli": {
"id": "gemini-cli",
"name": "Gemini CLI",
"type": "gemini-cli",
"api_key_required": false,
"gemini_cli_config": {
"cli_path": "/usr/local/bin/gemini",
"config_file": "~/.config/gemini/config.json"
}
}
}
```
---
### 2. Qwen CLI Integration
**Estimated Effort**: 2-3 days
**Expected Benefit**: Access to Alibaba's Qwen models via CLI
**ROI**: ⭐⭐⭐⭐ High
**Description**: Integrate Qwen CLI tool as a provider type, enabling access to Qwen's language models through their official command-line interface.
**Tasks**:
- [ ] Research Qwen CLI authentication and API structure
- [ ] Create `QwenCLIProviderHandler` class in `aisbf/providers.py`
- [ ] Implement CLI command execution and response parsing
- [ ] Add configuration schema to `config/providers.json`
- [ ] Test with available Qwen models
- [ ] Add streaming support if available
- [ ] Update documentation with setup instructions
- [ ] Add dashboard UI support for Qwen CLI configuration
**Configuration Example**:
```json
{
"qwen-cli": {
"id": "qwen-cli",
"name": "Qwen CLI",
"type": "qwen-cli",
"api_key_required": false,
"qwen_cli_config": {
"cli_path": "/usr/local/bin/qwen",
"api_key": "YOUR_QWEN_API_KEY"
}
}
}
```
---
### 3. GitHub Copilot CLI Integration
**Estimated Effort**: 3-4 days
**Expected Benefit**: Leverage GitHub Copilot's code-focused models
**ROI**: ⭐⭐⭐⭐⭐ Very High
**Description**: Integrate GitHub Copilot CLI as a provider type, allowing users to access Copilot's models through their GitHub authentication.
**Tasks**:
- [ ] Research GitHub Copilot CLI authentication flow
- [ ] Create `CopilotCLIProviderHandler` class in `aisbf/providers.py`
- [ ] Implement GitHub OAuth integration if needed
- [ ] Implement CLI command execution and response parsing
- [ ] Add configuration schema to `config/providers.json`
- [ ] Test with Copilot models
- [ ] Add streaming support
- [ ] Handle GitHub authentication tokens
- [ ] Update documentation with setup instructions
- [ ] Add dashboard UI support for Copilot CLI configuration
**Configuration Example**:
```json
{
"copilot-cli": {
"id": "copilot-cli",
"name": "GitHub Copilot CLI",
"type": "copilot-cli",
"api_key_required": false,
"copilot_cli_config": {
"cli_path": "/usr/local/bin/github-copilot-cli",
"auth_token": "ghp_xxxxxxxxxxxxx"
}
}
}
```
---
## 🔶 MEDIUM PRIORITY
### 4. Bolt.new Verification
**Estimated Effort**: 1-2 days
**Expected Benefit**: Verify compatibility with Bolt.new service
**ROI**: ⭐⭐⭐ Medium
**Description**: Verify that AISBF can work with Bolt.new (StackBlitz's AI-powered full-stack web development tool) and document any integration requirements.
**Tasks**:
- [ ] Research Bolt.new API structure and authentication
- [ ] Test existing AISBF providers with Bolt.new
- [ ] Identify any compatibility issues
- [ ] Document integration steps
- [ ] Create example configurations
- [ ] Test with various Bolt.new features
- [ ] Update documentation with Bolt.new integration guide
---
### 5. DeepSeek Verification
**Estimated Effort**: 1-2 days
**Expected Benefit**: Verify compatibility with DeepSeek API
**ROI**: ⭐⭐⭐ Medium
**Description**: Verify that AISBF's existing OpenAI-compatible provider handler works correctly with DeepSeek's API, or create a dedicated handler if needed.
**Tasks**:
- [ ] Research DeepSeek API structure and authentication
- [ ] Test with existing OpenAI provider handler
- [ ] Identify any API differences or incompatibilities
- [ ] Create dedicated `DeepSeekProviderHandler` if needed
- [ ] Add configuration example to `config/providers.json`
- [ ] Test with various DeepSeek models
- [ ] Document any special requirements or limitations
- [ ] Update documentation with DeepSeek integration guide
**Configuration Example**:
```json
{
"deepseek": {
"id": "deepseek",
"name": "DeepSeek",
"endpoint": "https://api.deepseek.com/v1",
"type": "openai",
"api_key_required": true,
"api_key": "YOUR_DEEPSEEK_API_KEY"
}
}
```
---
### 6. Rovo Dev CLI Verification
**Estimated Effort**: 1-2 days
**Expected Benefit**: Verify compatibility with Atlassian Rovo Dev CLI
**ROI**: ⭐⭐⭐ Medium
**Description**: Verify that AISBF can integrate with Atlassian's Rovo Dev CLI tool and document the integration process.
**Tasks**:
- [ ] Research Rovo Dev CLI authentication and API structure
- [ ] Test existing AISBF providers with Rovo Dev CLI
- [ ] Identify integration requirements
- [ ] Create dedicated handler if needed
- [ ] Add configuration schema
- [ ] Test with Rovo Dev features
- [ ] Document integration steps
- [ ] Update documentation with Rovo Dev CLI guide
---
## 📋 Implementation Notes
### General CLI Integration Pattern
When integrating CLI tools, follow this pattern:
1. **Authentication**: Determine how the CLI tool handles authentication (config files, environment variables, OAuth tokens)
2. **Command Execution**: Use Python's `subprocess` module to execute CLI commands
3. **Response Parsing**: Parse CLI output (JSON, plain text, etc.) into AISBF's standard format
4. **Error Handling**: Handle CLI errors, timeouts, and authentication failures
5. **Streaming**: Implement streaming if the CLI tool supports it (parse output line-by-line)
6. **Configuration**: Add CLI-specific configuration fields (cli_path, config_file, etc.)
### Testing Checklist
For each CLI tool integration:
- [ ] Test authentication flow
- [ ] Test basic chat completion
- [ ] Test streaming responses
- [ ] Test error handling
- [ ] Test with multiple models
- [ ] Test rate limiting
- [ ] Test in rotations
- [ ] Test in autoselect
- [ ] Verify dashboard configuration UI
- [ ] Update documentation
---
## 🔵 Future Enhancements
### Additional CLI Tools to Consider
- Claude CLI (official Anthropic CLI)
- Mistral CLI
- Cohere CLI
- AI21 CLI
- Perplexity CLI
### CLI Tool Management Features
- [ ] CLI tool version detection and compatibility checking
- [ ] Automatic CLI tool installation/update
- [ ] CLI tool health monitoring
- [ ] CLI tool performance benchmarking
- [ ] Unified CLI tool configuration interface
---
## 📚 Documentation Updates Required
When completing CLI tool integrations:
1. Update `README.md` with CLI tool support section
2. Update `DOCUMENTATION.md` with detailed CLI tool setup guides
3. Update `AI.PROMPT` with CLI tool configuration patterns
4. Add CLI tool examples to `API_EXAMPLES.md`
5. Update dashboard help text for CLI tool configuration
- [ ] Add support for mempalace
- [ ] Add support for caveman mode
- [ ] Integrate github larsderidder context-lens project
......@@ -162,6 +162,8 @@ ensure_venv() {
if [ -f "$SHARE_DIR/requirements.txt" ]; then
echo "Installing requirements from $SHARE_DIR/requirements.txt"
"$VENV_DIR/bin/pip" install -r "$SHARE_DIR/requirements.txt"
# Force reinstall uvicorn in venv to ensure it's available inside the virtual environment
"$VENV_DIR/bin/pip" install --force-reinstall uvicorn
fi
# Save version for future upgrade detection
......@@ -173,6 +175,8 @@ ensure_venv() {
# Only update requirements, aisbf is accessed from system site-packages
if [ -f "$SHARE_DIR/requirements.txt" ]; then
"$VENV_DIR/bin/pip" install -r "$SHARE_DIR/requirements.txt"
# Force reinstall uvicorn in venv to ensure it's available inside the virtual environment
"$VENV_DIR/bin/pip" install --force-reinstall uvicorn
fi
python3 -c "import aisbf; print(aisbf.__version__)" > "$VENV_DIR/.aisbf_version" 2>/dev/null || echo "unknown" > "$VENV_DIR/.aisbf_version"
echo "Virtual environment updated successfully"
......@@ -191,9 +195,13 @@ update_venv() {
if [ $ALREADY_SATISFIED -ne 0 ]; then
echo "Installing new requirements (this will take a while!) ... "
"$VENV_DIR/bin/pip" install -r "$SHARE_DIR/requirements.txt"
# Force reinstall uvicorn in venv to ensure it's available inside the virtual environment
"$VENV_DIR/bin/pip" install --force-reinstall uvicorn
echo "[OK]"
else
echo "Virtual env already up to date"
# Still force reinstall uvicorn to ensure it's in venv
"$VENV_DIR/bin/pip" install --force-reinstall uvicorn
fi
fi
}
......
......@@ -54,7 +54,7 @@ from .auth.qwen import QwenOAuth2
from .handlers import RequestHandler, RotationHandler, AutoselectHandler
from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model
__version__ = "0.99.20"
__version__ = "0.99.21"
__all__ = [
# Config
"config",
......
......@@ -124,7 +124,8 @@ class Analytics:
self,
provider_id: str,
from_datetime: Optional[datetime] = None,
to_datetime: Optional[datetime] = None
to_datetime: Optional[datetime] = None,
user_filter: Optional[int] = None
) -> Dict[str, Any]:
"""
Get statistics for a specific provider.
......@@ -177,7 +178,8 @@ class Analytics:
self,
provider_id: str,
from_datetime: datetime,
to_datetime: datetime
to_datetime: datetime,
user_filter: Optional[int] = None
) -> Dict[str, Any]:
"""Get provider stats from database for a specific date range."""
# This is a placeholder - in a real implementation, you'd query the database
......@@ -194,7 +196,8 @@ class Analytics:
self,
provider_id: Optional[str] = None,
from_datetime: Optional[datetime] = None,
to_datetime: Optional[datetime] = None
to_datetime: Optional[datetime] = None,
user_filter: Optional[int] = None
) -> Dict[str, Any]:
"""
Get token usage for a specific date range.
......@@ -254,7 +257,8 @@ class Analytics:
def get_all_providers_stats(
self,
from_datetime: Optional[datetime] = None,
to_datetime: Optional[datetime] = None
to_datetime: Optional[datetime] = None,
user_filter: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Get statistics for all providers.
......@@ -360,17 +364,19 @@ class Analytics:
cursor = conn.cursor()
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
# Determine date format based on database type
# Determine date format and time bucket expression based on database type
if self.db.db_type == 'sqlite':
date_format = "%Y-%m-%d %H:%M"
time_bucket_expr = f"strftime('{date_format}', timestamp)"
else:
date_format = "%Y-%m-%d %H:%i"
time_bucket_expr = f"DATE_FORMAT(timestamp, '{date_format}')"
if provider_id:
if user_filter:
cursor.execute(f'''
SELECT
strftime('{date_format}', timestamp) as time_bucket,
{time_bucket_expr} as time_bucket,
SUM(tokens_used) as tokens
FROM token_usage
WHERE provider_id = {placeholder} AND user_id = {placeholder} AND timestamp >= {placeholder} AND timestamp <= {placeholder}
......@@ -380,7 +386,7 @@ class Analytics:
else:
cursor.execute(f'''
SELECT
strftime('{date_format}', timestamp) as time_bucket,
{time_bucket_expr} as time_bucket,
SUM(tokens_used) as tokens
FROM token_usage
WHERE provider_id = {placeholder} AND timestamp >= {placeholder} AND timestamp <= {placeholder}
......@@ -391,7 +397,7 @@ class Analytics:
if user_filter:
cursor.execute(f'''
SELECT
strftime('{date_format}', timestamp) as time_bucket,
{time_bucket_expr} as time_bucket,
SUM(tokens_used) as tokens,
provider_id
FROM token_usage
......@@ -402,7 +408,7 @@ class Analytics:
else:
cursor.execute(f'''
SELECT
strftime('{date_format}', timestamp) as time_bucket,
{time_bucket_expr} as time_bucket,
SUM(tokens_used) as tokens,
provider_id
FROM token_usage
......@@ -610,7 +616,8 @@ class Analytics:
def get_cost_overview(
self,
from_datetime: Optional[datetime] = None,
to_datetime: Optional[datetime] = None
to_datetime: Optional[datetime] = None,
user_filter: Optional[int] = None
) -> Dict[str, Any]:
"""
Get cost overview for all providers.
......@@ -665,7 +672,7 @@ class Analytics:
}
}
def get_optimization_recommendations(self) -> List[Dict[str, Any]]:
def get_optimization_recommendations(self, user_filter: Optional[int] = None) -> List[Dict[str, Any]]:
"""
Generate optimization recommendations based on analytics.
......@@ -919,12 +926,14 @@ class Analytics:
if self.db.db_type == 'sqlite':
date_format = "%Y-%m-%d %H:%M"
time_bucket_expr = f"strftime('{date_format}', timestamp)"
else:
date_format = "%Y-%m-%d %H:%i"
time_bucket_expr = f"DATE_FORMAT(timestamp, '{date_format}')"
cursor.execute(f'''
SELECT
strftime('{date_format}', timestamp) as time_bucket,
{time_bucket_expr} as time_bucket,
SUM(tokens_used) as tokens,
provider_id
FROM token_usage
......
This diff is collapsed.
......@@ -239,6 +239,7 @@ class CurrencyConfig(BaseModel):
class SMTPConfig(BaseModel):
"""Configuration for SMTP email sending"""
enabled: bool = False
host: str = "localhost"
port: int = 587
username: str = ""
......
This source diff could not be displayed because it is too large. You can view the blob instead.
This diff is collapsed.
......@@ -24,7 +24,7 @@ import asyncio
import time
import os
import random
from typing import Dict, List, Optional, Union
from typing import Dict, List, Optional, Union, Any
from ..models import Provider, Model, ErrorTracking
from ..config import config
from ..utils import count_messages_tokens
......@@ -1263,6 +1263,51 @@ class BaseProviderHandler:
# Fall back to direct processing (either batching disabled, streaming, or batching returned None)
return await self._handle_request_direct(model, messages, max_tokens, temperature, stream, tools, tool_choice)
def _get_models_cache_key(self) -> str:
"""Get unified cache key for provider models list"""
return f"provider_models:{self.provider_id}"
def _save_models_cache(self, models: List[Any]) -> None:
"""Save models list to unified cache system"""
import logging
try:
from ..cache import get_cache_manager
cache = get_cache_manager()
cache_data = {
'models': [m.dict() if hasattr(m, 'dict') else dict(m) for m in models],
'cached_at': time.time()
}
# Cache for 24 hours (86400 seconds)
cache.set(self._get_models_cache_key(), cache_data, ttl=86400)
logging.info(f"{self.__class__.__name__}: ✓ Saved {len(models)} models to cache")
except Exception as e:
logging.warning(f"{self.__class__.__name__}: Failed to save models cache: {e}")
def _load_models_cache(self) -> Optional[List[Any]]:
"""Load models list from unified cache system if available and not expired"""
import logging
try:
from ..cache import get_cache_manager
from ..models import Model
cache = get_cache_manager()
cache_data = cache.get(self._get_models_cache_key())
if not cache_data:
return None
models = []
for model_dict in cache_data.get('models', []):
models.append(Model(**model_dict))
logging.info(f"{self.__class__.__name__}: ✓ Loaded {len(models)} models from cache")
return models
except Exception as e:
logging.warning(f"{self.__class__.__name__}: Failed to load models cache: {e}")
return None
async def _handle_request_direct(self, model: str, messages: List[Dict], max_tokens: Optional[int] = None,
temperature: Optional[float] = 1.0, stream: Optional[bool] = False,
tools: Optional[List[Dict]] = None, tool_choice: Optional[Union[str, Dict]] = None) -> Union[Dict, object]:
......
......@@ -1623,97 +1623,8 @@ class ClaudeProviderHandler(BaseProviderHandler):
'cache_hit_rate_percent': round(hit_rate, 2),
}
def _get_models_cache_path(self) -> str:
"""Get the path to the models cache file."""
import os
cache_dir = os.path.expanduser("~/.aisbf")
os.makedirs(cache_dir, exist_ok=True)
return os.path.join(cache_dir, f"claude_models_cache_{self.provider_id}.json")
def _save_models_cache(self, models: List[Model]) -> None:
"""Save models to cache file."""
import logging
import json
try:
cache_path = self._get_models_cache_path()
cache_data = {
'timestamp': time.time(),
'models': []
}
for m in models:
model_dict = {'id': m.id, 'name': m.name}
if m.context_size:
model_dict['context_size'] = m.context_size
if m.context_length:
model_dict['context_length'] = m.context_length
if m.description:
model_dict['description'] = m.description
if m.pricing:
model_dict['pricing'] = m.pricing
if m.top_provider:
model_dict['top_provider'] = m.top_provider
if m.supported_parameters:
model_dict['supported_parameters'] = m.supported_parameters
cache_data['models'].append(model_dict)
with open(cache_path, 'w') as f:
json.dump(cache_data, f, indent=2)
logging.info(f"ClaudeProviderHandler: ✓ Saved {len(models)} models to cache: {cache_path}")
except Exception as e:
logging.warning(f"ClaudeProviderHandler: Failed to save models cache: {e}")
def _load_models_cache(self) -> Optional[List[Model]]:
"""Load models from cache file if available and not too old."""
import logging
import json
import os
try:
cache_path = self._get_models_cache_path()
if not os.path.exists(cache_path):
logging.info(f"ClaudeProviderHandler: No cache file found at {cache_path}")
return None
with open(cache_path, 'r') as f:
cache_data = json.load(f)
cache_age = time.time() - cache_data.get('timestamp', 0)
cache_age_hours = cache_age / 3600
logging.info(f"ClaudeProviderHandler: Found cache file (age: {cache_age_hours:.1f} hours)")
if cache_age > 86400:
logging.info(f"ClaudeProviderHandler: Cache is too old (>{cache_age_hours:.1f} hours), ignoring")
return None
models = []
for m in cache_data.get('models', []):
models.append(Model(
id=m['id'],
name=m['name'],
provider_id=self.provider_id,
context_size=m.get('context_size'),
context_length=m.get('context_length'),
description=m.get('description'),
pricing=m.get('pricing'),
top_provider=m.get('top_provider'),
supported_parameters=m.get('supported_parameters')
))
if models:
logging.info(f"ClaudeProviderHandler: ✓ Loaded {len(models)} models from cache")
return models
else:
logging.info(f"ClaudeProviderHandler: Cache file is empty")
return None
except Exception as e:
logging.warning(f"ClaudeProviderHandler: Failed to load models cache: {e}")
return None
# Model caching is now handled by the base class using the unified cache system
# _get_models_cache_path(), _save_models_cache(), _load_models_cache() are inherited from BaseProviderHandler
async def get_models(self) -> List[Model]:
"""Return list of available Claude models by querying the API."""
......
......@@ -391,89 +391,8 @@ class KiroProviderHandler(BaseProviderHandler):
# Final flush to ensure all buffered data reaches the client
await asyncio.sleep(0)
def _get_models_cache_path(self) -> str:
"""Get the path to the models cache file."""
cache_dir = os.path.expanduser("~/.aisbf")
os.makedirs(cache_dir, exist_ok=True)
return os.path.join(cache_dir, f"kiro_models_cache_{self.provider_id}.json")
def _save_models_cache(self, models: List[Model]) -> None:
"""Save models to cache file."""
try:
cache_path = self._get_models_cache_path()
cache_data = {
'timestamp': time.time(),
'models': []
}
for m in models:
model_dict = {'id': m.id, 'name': m.name}
if m.context_size:
model_dict['context_size'] = m.context_size
if m.context_length:
model_dict['context_length'] = m.context_length
if m.description:
model_dict['description'] = m.description
if m.pricing:
model_dict['pricing'] = m.pricing
if m.top_provider:
model_dict['top_provider'] = m.top_provider
if m.supported_parameters:
model_dict['supported_parameters'] = m.supported_parameters
cache_data['models'].append(model_dict)
with open(cache_path, 'w') as f:
json.dump(cache_data, f, indent=2)
logging.info(f"KiroProviderHandler: ✓ Saved {len(models)} models to cache: {cache_path}")
except Exception as e:
logging.warning(f"KiroProviderHandler: Failed to save models cache: {e}")
def _load_models_cache(self) -> Optional[List[Model]]:
"""Load models from cache file if available and not too old."""
try:
cache_path = self._get_models_cache_path()
if not os.path.exists(cache_path):
logging.info(f"KiroProviderHandler: No cache file found at {cache_path}")
return None
with open(cache_path, 'r') as f:
cache_data = json.load(f)
cache_age = time.time() - cache_data.get('timestamp', 0)
cache_age_hours = cache_age / 3600
logging.info(f"KiroProviderHandler: Found cache file (age: {cache_age_hours:.1f} hours)")
if cache_age > 86400:
logging.info(f"KiroProviderHandler: Cache is too old (>{cache_age_hours:.1f} hours), ignoring")
return None
models = []
for m in cache_data.get('models', []):
models.append(Model(
id=m['id'],
name=m['name'],
provider_id=self.provider_id,
context_size=m.get('context_size'),
context_length=m.get('context_length'),
description=m.get('description'),
pricing=m.get('pricing'),
top_provider=m.get('top_provider'),
supported_parameters=m.get('supported_parameters')
))
if models:
logging.info(f"KiroProviderHandler: ✓ Loaded {len(models)} models from cache")
return models
else:
logging.info(f"KiroProviderHandler: Cache file is empty")
return None
except Exception as e:
logging.warning(f"KiroProviderHandler: Failed to load models cache: {e}")
return None
# Model caching is now handled by the base class using the unified cache system
# _get_models_cache_path(), _save_models_cache(), _load_models_cache() are inherited from BaseProviderHandler
async def get_models(self) -> List[Model]:
"""Return list of available models using fallback strategy."""
......
......@@ -137,8 +137,17 @@ class TorHiddenService:
('HiddenServicePort', f'{self.config.hidden_service_port} 127.0.0.1:{local_port}')
])
# Read the hostname file to get onion address
# Wait for Tor daemon to create the hostname file (can take up to 30 seconds)
import time
hostname_file = hidden_service_dir / 'hostname'
# Wait up to 30 seconds with 1 second intervals
for attempt in range(30):
if hostname_file.exists() and hostname_file.stat().st_size > 0:
break
time.sleep(1)
logger.debug(f"Waiting for hostname file... attempt {attempt + 1}/30")
if hostname_file.exists():
self.onion_address = hostname_file.read_text().strip()
logger.info(f"Persistent hidden service created: {self.onion_address}")
......
......@@ -123,6 +123,7 @@
"verification_token_expiry_hours": 24
},
"smtp": {
"enabled": false,
"host": "localhost",
"port": 587,
"username": "",
......
This diff is collapsed.
......@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "aisbf"
version = "0.99.20"
version = "0.99.21"
description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations"
readme = "README.md"
license = "GPL-3.0-or-later"
......
......@@ -112,6 +112,7 @@ setup(
'aisbf/classifier.py',
'aisbf/streaming_optimization.py',
'aisbf/analytics.py',
'aisbf/email_utils.py',
]),
# aisbf.providers subpackage
('share/aisbf/aisbf/providers', [
......@@ -170,8 +171,13 @@ setup(
'templates/dashboard/rate_limits.html',
'templates/dashboard/users.html',
'templates/dashboard/signup.html',
'templates/dashboard/verify.html',
'templates/dashboard/forgot_password.html',
'templates/dashboard/reset_password.html',
'templates/dashboard/profile.html',
'templates/dashboard/change_password.html',
'templates/dashboard/change_email.html',
'templates/dashboard/delete_account.html',
'templates/dashboard/admin_tiers.html',
'templates/dashboard/admin_tier_form.html',
'templates/dashboard/pricing.html',
......
......@@ -124,6 +124,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</select>
</div>
{% if is_admin %}
<div style="flex: 1; min-width: 150px;">
<label style="display: block; margin-bottom: 5px; color: #a0a0a0; font-size: 14px;">User</label>
<select name="user_filter" style="width: 100%; padding: 10px; border-radius: 4px; background: #0f3460; color: white; border: 1px solid #2a4a7a;">
......@@ -133,6 +134,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% endfor %}
</select>
</div>
{% endif %}
<div>
<button type="submit" style="padding: 10px 20px; background: #3498db; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
......
{% extends "base.html" %}
{% block title %}Change Email - AISBF{% endblock %}
{% block content %}
<div class="container">
<h1>Change Email Address</h1>
<p>Update your email address. You will need to verify the new email before it takes effect.</p>
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<div class="card">
<h2>Current Email</h2>
<p style="color: #a0a0a0; margin-bottom: 2rem;">{{ session.email }}</p>
<form method="POST" action="{{ url_for(request, '/dashboard/change-email') }}">
<div class="form-group">
<label for="new_email">New Email Address</label>
<input type="email" id="new_email" name="new_email" required>
</div>
<div class="form-group">
<label for="password">Current Password</label>
<input type="password" id="password" name="password" required>
<small style="color: #a0a0a0; display: block; margin-top: 0.5rem;">Confirm your password to proceed</small>
</div>
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
<button type="submit" class="btn btn-primary">Send Verification Email</button>
<a href="{{ url_for(request, '/dashboard/profile') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
<style>
.container {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
.card {
background: #16213e;
padding: 2rem;
border-radius: 8px;
margin-top: 1.5rem;
}
.card h2 {
margin-bottom: 1.5rem;
color: #e0e0e0;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #e0e0e0;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #0f3460;
border-radius: 4px;
background: #1a1a2e;
color: #e0e0e0;
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.alert-success {
background: #1b5e20;
color: #a5d6a7;
border: 1px solid #2e7d32;
}
.alert-error {
background: #b71c1c;
color: #ef9a9a;
border: 1px solid #c62828;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #0f3460;
color: #e0e0e0;
}
.btn-secondary:hover {
background: #16213e;
}
</style>
{% endblock %}
{% extends "base.html" %}
{% block title %}Delete Account - AISBF{% endblock %}
{% block content %}
<div class="container">
<h1 style="color: #f44336;">Delete Account</h1>
<p>This action cannot be undone. All your data will be permanently deleted.</p>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% if has_subscription %}
<div class="alert alert-warning">
<strong>⚠️ Warning: Active Subscription Detected</strong>
<p style="margin-top: 0.5rem;">You have an active paid subscription ({{ subscription_tier }}). Deleting your account will:</p>
<ul style="margin-left: 1.5rem; margin-top: 0.5rem;">
<li>Cancel your subscription immediately</li>
<li>You will lose access to all premium features</li>
<li>No refunds will be issued for remaining subscription time</li>
</ul>
<p style="margin-top: 0.5rem;">Consider canceling your subscription first if you want to use it until the end of the billing period.</p>
</div>
{% endif %}
<div class="card danger-card">
<h2>Confirm Account Deletion</h2>
<div class="warning-box">
<h3>⚠️ This will permanently delete:</h3>
<ul>
<li>Your account and profile information</li>
<li>All your API providers and configurations</li>
<li>All your rotation and autoselect settings</li>
<li>All your usage history and analytics</li>
<li>All your API tokens</li>
{% if has_subscription %}
<li><strong>Your active subscription ({{ subscription_tier }})</strong></li>
{% endif %}
</ul>
</div>
<form method="POST" action="{{ url_for(request, '/dashboard/delete-account') }}" onsubmit="return confirmDeletion()">
<div class="form-group">
<label for="password">Enter Your Password to Confirm</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label for="confirmation">Type "DELETE" to confirm</label>
<input type="text" id="confirmation" name="confirmation" required placeholder="DELETE">
</div>
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
<button type="submit" class="btn btn-danger">Delete My Account Permanently</button>
<a href="{{ url_for(request, '/dashboard/profile') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
<style>
.container {
margin: 0 auto;
padding: 2rem;
}
.card {
background: #16213e;
padding: 2rem;
border-radius: 8px;
margin-top: 1.5rem;
}
.danger-card {
border: 2px solid #d32f2f;
}
.card h2 {
margin-bottom: 1.5rem;
color: #f44336;
}
.warning-box {
background: #1a1a2e;
border-left: 4px solid #f44336;
padding: 1rem;
margin-bottom: 1.5rem;
border-radius: 4px;
}
.warning-box h3 {
color: #f44336;
margin-bottom: 0.75rem;
font-size: 1.1rem;
}
.warning-box ul {
margin-left: 1.5rem;
color: #e0e0e0;
}
.warning-box li {
margin-bottom: 0.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #e0e0e0;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #0f3460;
border-radius: 4px;
background: #1a1a2e;
color: #e0e0e0;
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: #f44336;
}
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.alert-error {
background: #b71c1c;
color: #ef9a9a;
border: 1px solid #c62828;
}
.alert-warning {
background: #e65100;
color: #ffcc80;
border: 1px solid #ef6c00;
}
.alert-warning strong {
display: block;
margin-bottom: 0.5rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
text-decoration: none;
display: inline-block;
}
.btn-danger {
background: #d32f2f;
color: white;
font-weight: 600;
}
.btn-danger:hover {
background: #b71c1c;
}
.btn-secondary {
background: #0f3460;
color: #e0e0e0;
}
.btn-secondary:hover {
background: #16213e;
}
</style>
<script>
function confirmDeletion() {
const confirmation = document.getElementById('confirmation').value;
if (confirmation !== 'DELETE') {
alert('Please type "DELETE" exactly to confirm account deletion.');
return false;
}
return confirm('Are you absolutely sure? This action cannot be undone and all your data will be permanently deleted.');
}
</script>
{% endblock %}
{% extends "base.html" %}
{% block title %}Forgot Password{% endblock %}
{% block header_css %}
<style>
.login-container {
max-width: 450px;
margin: 80px auto;
padding: 30px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.login-container h2 {
text-align: center;
margin-bottom: 25px;
color: #333;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #444;
}
.form-group input {
width: 100%;
padding: 12px 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #2196F3;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
}
.btn {
background: #2196F3;
color: white;
border: none;
padding: 12px 20px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
}
.btn-secondary {
background: #666;
}
.btn-secondary:hover {
background: #555;
}
.button-row {
display: flex;
gap: 10px;
margin-top: 20px;
}
.button-row .btn {
flex: 1;
width: auto;
}
.btn:hover {
background: #1976D2;
}
.alert {
padding: 12px;
margin-bottom: 20px;
border-radius: 4px;
text-align: center;
}
.alert.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.back-link {
text-align: center;
margin-top: 20px;
}
.back-link a {
color: #666;
text-decoration: none;
}
.back-link a:hover {
text-decoration: underline;
}
</style>
{% endblock %}
{% block content %}
<div class="login-container">
<h2>Reset Your Password</h2>
{% if message %}
<div class="alert {{ message_type }}">
{{ message }}
</div>
{% endif %}
{% if success %}
<p style="text-align: center; color: #155724; line-height: 1.6;">
If an account exists with that email address, we have sent a password reset link.
<br><br>
The link will expire in 24 hours. Please check your inbox and spam folder.
</p>
<div class="button-row">
<a href="{{ url_for(request, '/dashboard/login') }}" class="btn" style="text-decoration: none; text-align: center; width: 100%;">Back to Login</a>
</div>
{% else %}
<p style="text-align: center; color: #666; margin-bottom: 25px;">
Enter your email address and we'll send you a link to reset your password.
</p>
<form method="POST">
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" required autofocus>
</div>
<div class="button-row">
<button type="submit" class="btn">Send Reset Link</button>
<a href="{{ url_for(request, '/dashboard/login') }}" class="btn btn-secondary" style="text-decoration: none; text-align: center;">Back to Login</a>
</div>
</form>
{% endif %}
</div>
{% endblock %}
......@@ -26,6 +26,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
{% if show_verify_email %}
<div class="alert alert-info">Please check your email and verify your account before logging in.</div>
{% endif %}
<form method="POST" action="{{ url_for(request, '/dashboard/login') }}">
<div class="form-group">
<label for="username">Username</label>
......@@ -46,8 +54,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<button type="submit" class="btn" style="width: 100%;">Login</button>
{% if signup_enabled %}
{% if smtp_enabled %}
<div style="text-align: center; margin-top: 20px;">
<p><a href="{{ url_for(request, '/dashboard/forgot-password') }}" style="color: #2196F3; text-decoration: none;">Forgot your password?</a></p>
</div>
{% endif %}
{% if signup_enabled %}
<div style="text-align: center; margin-top: 10px;">
<p>Don't have an account? <a href="{{ url_for(request, '/dashboard/signup') }}" style="color: #4CAF50;">Sign up here</a></p>
</div>
{% endif %}
......
......@@ -26,7 +26,16 @@
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" value="{{ session.email }}" required>
<input type="email" id="email" name="email" value="{{ session.email or '' }}" readonly style="background: #0f1419; cursor: not-allowed;">
{% if not session.email %}
<small style="color: #a0a0a0; display: block; margin-top: 0.5rem;">
No email address set. <a href="{{ url_for(request, '/dashboard/change-email') }}" style="color: #667eea;">Add Email</a> (requires verification)
</small>
{% else %}
<small style="color: #a0a0a0; display: block; margin-top: 0.5rem;">
<a href="{{ url_for(request, '/dashboard/change-email') }}" style="color: #667eea;">Change Email</a> (requires verification)
</small>
{% endif %}
</div>
<div class="form-group">
......@@ -42,6 +51,12 @@
</div>
</form>
</div>
<div class="card" style="margin-top: 2rem; border: 1px solid #d32f2f;">
<h2 style="color: #f44336;">Danger Zone</h2>
<p style="color: #a0a0a0; margin-bottom: 1rem;">Permanently delete your account and all associated data.</p>
<a href="{{ url_for(request, '/dashboard/delete-account') }}" class="btn" style="background: #d32f2f; color: white;">Delete Account</a>
</div>
</div>
<style>
......
{% extends "base.html" %}
{% block title %}Reset Password{% endblock %}
{% block header_css %}
<style>
.login-container {
max-width: 450px;
margin: 80px auto;
padding: 30px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.login-container h2 {
text-align: center;
margin-bottom: 25px;
color: #333;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #444;
}
.form-group input {
width: 100%;
padding: 12px 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #2196F3;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
}
.btn {
background: #2196F3;
color: white;
border: none;
padding: 12px 20px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
width: 100%;
transition: background 0.2s;
}
.btn:hover {
background: #1976D2;
}
.alert {
padding: 12px;
margin-bottom: 20px;
border-radius: 4px;
text-align: center;
}
.alert.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.back-link {
text-align: center;
margin-top: 20px;
}
.back-link a {
color: #666;
text-decoration: none;
}
.back-link a:hover {
text-decoration: underline;
}
.password-hint {
font-size: 12px;
color: #666;
margin-top: 5px;
}
</style>
{% endblock %}
{% block content %}
<div class="login-container">
<h2>Set New Password</h2>
{% if message %}
<div class="alert {{ message_type }}">
{{ message }}
</div>
{% endif %}
{% if valid_token and not success %}
<p style="text-align: center; color: #666; margin-bottom: 25px;">
Please enter your new password below.
</p>
<form method="POST">
<div class="form-group">
<label for="password">New Password</label>
<input type="password" id="password" name="password" required minlength="8">
<div class="password-hint">Must be at least 8 characters long</div>
</div>
<div class="form-group">
<label for="password_confirm">Confirm Password</label>
<input type="password" id="password_confirm" name="password_confirm" required>
</div>
<button type="submit" class="btn">Reset Password</button>
</form>
{% elif success %}
<p style="text-align: center; color: #155724; line-height: 1.6;">
Your password has been successfully reset.
<br><br>
You can now login with your new password.
</p>
<div style="text-align: center; margin-top: 25px;">
<a href="{{ url_for(request, '/dashboard/login') }}" class="btn" style="display: inline-block; text-decoration: none; width: auto; padding: 10px 25px;">
Go to Login
</a>
</div>
{% else %}
<p style="text-align: center; color: #721c24; line-height: 1.6;">
This password reset link is invalid or has expired.
<br><br>
Please request a new password reset link.
</p>
<div style="text-align: center; margin-top: 25px;">
<a href="{{ url_for(request, '/dashboard/forgot-password') }}" class="btn" style="display: inline-block; text-decoration: none; width: auto; padding: 10px 25px;">
Request New Reset Link
</a>
</div>
{% endif %}
</div>
{% endblock %}
......@@ -584,6 +584,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<h3 style="margin: 30px 0 20px;">SMTP Email Configuration</h3>
<div class="form-group">
<label>
<input type="checkbox" name="smtp_enabled" {% if config.smtp and config.smtp.enabled %}checked{% endif %}>
Enable SMTP Email
</label>
<small style="color: #666; display: block; margin-top: 5px;">Enable email sending functionality for password resets, account verification, and notifications</small>
</div>
<div class="form-group">
<label for="smtp_host">SMTP Host</label>
<input type="text" id="smtp_host" name="smtp_host"
......@@ -641,6 +649,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<small style="color: #666; display: block; margin-top: 5px;">Display name for sent emails</small>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<input type="email" id="smtp_test_email" name="smtp_test_email" placeholder="Email address to send test to" style="flex: 1;">
<button type="button" onclick="testSMTP()" class="btn btn-secondary">Send Test Email</button>
</div>
<h3 style="margin: 30px 0 20px;">Request Batching</h3>
<div class="form-group">
......@@ -786,13 +799,19 @@ async function testSMTP() {
const port = document.getElementById('smtp_port').value;
const username = document.getElementById('smtp_username').value;
const fromEmail = document.getElementById('smtp_from_email').value;
const testEmail = document.getElementById('smtp_test_email').value;
if (!host || !port || !fromEmail) {
alert('Please fill in SMTP host, port, and from email before testing');
return;
}
if (!confirm('This will send a test email to the admin email address. Continue?')) {
if (!testEmail) {
alert('Please enter a test recipient email address');
return;
}
if (!confirm(`This will send a test email to ${testEmail}. Continue?`)) {
return;
}
......@@ -808,7 +827,8 @@ async function testSMTP() {
use_tls: document.querySelector('input[name="smtp_use_tls"]').checked,
use_ssl: document.querySelector('input[name="smtp_use_ssl"]').checked,
from_email: fromEmail,
from_name: document.getElementById('smtp_from_name').value
from_name: document.getElementById('smtp_from_name').value,
test_recipient: testEmail
})
});
......
......@@ -33,11 +33,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus
pattern="[a-zA-Z0-9_-]+"
title="Username can only contain letters, numbers, underscores, and hyphens"
pattern="[a-zA-Z0-9_.-]+"
title="Username can only contain letters, numbers, underscores, hyphens, and dots"
minlength="3" maxlength="50">
<small style="color: #666; display: block; margin-top: 5px;">
3-50 characters, letters, numbers, underscores, and hyphens only
3-50 characters, letters, numbers, underscores, hyphens, and dots only
</small>
</div>
......
<!--
Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
{% extends "base.html" %}
{% block title %}Verify Email - AISBF Dashboard{% endblock %}
{% block content %}
<div style="max-width: 500px; margin: 50px auto;">
<h2 style="margin-bottom: 30px; text-align: center;">Verify Your Email</h2>
<p style="text-align: center; margin-bottom: 30px;">
Please check your email inbox and click the verification link to activate your account.
</p>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% if message %}
<div class="alert alert-success">{{ message }}</div>
{% endif %}
<div style="background: #f5f5f5; padding: 20px; border-radius: 8px; margin-bottom: 30px;">
<h3 style="margin-top: 0; color: #333;">Didn't receive the email?</h3>
<p style="margin-bottom: 20px;">Check your spam/junk folder, or request a new verification email.</p>
{% if can_resend %}
<form method="POST" action="{{ url_for(request, '/dashboard/resend-verification') }}">
<button type="submit" class="btn" style="width: 100%;">Resend Verification Email</button>
</form>
{% else %}
<p style="color: #666; font-style: italic;">You can request another verification email in a few minutes.</p>
{% endif %}
</div>
<div style="text-align: center;">
<p><a href="{{ url_for(request, '/dashboard/logout') }}" style="color: #2196F3; text-decoration: none;">Logout</a></p>
</div>
</div>
{% endblock %}
\ No newline at end of file
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