Commit f8683a3d authored by Your Name's avatar Your Name

Release 0.99.25

parent 5246c1ae
......@@ -775,7 +775,7 @@ AISBF provides user-specific API endpoints that allow authenticated users to acc
All user-specific endpoints require authentication via Bearer token:
```bash
curl -H "Authorization: Bearer YOUR_USER_TOKEN" http://localhost:17765/api/user/models
curl -H "Authorization: Bearer YOUR_USER_TOKEN" http://localhost:17765/api/u/yourusername/models
```
Generate a user token from the dashboard: **Dashboard > My Account > API Tokens**
......@@ -788,7 +788,7 @@ Returns all models from the user's own providers, rotations, and autoselects:
```bash
# Get all user models
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/user/models
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/yourusername/models
```
Response includes:
......@@ -801,7 +801,7 @@ Response includes:
Returns all user-configured providers:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/user/providers
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/yourusername/providers
```
#### List User Rotations
......@@ -809,7 +809,7 @@ curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/user/provi
Returns all user-configured rotations:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/user/rotations
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/yourusername/rotations
```
#### List User Autoselects
......@@ -817,7 +817,7 @@ curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/user/rotat
Returns all user-configured autoselects:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/user/autoselects
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/yourusername/autoselects
```
#### User Chat Completions
......@@ -831,7 +831,7 @@ curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \
"model": "user-rotation/myrotation",
"messages": [{"role": "user", "content": "Hello"}]
}' \
http://localhost:17765/api/user/chat/completions
http://localhost:17765/api/u/yourusername/chat/completions
```
**Model formats for user endpoints:**
......@@ -850,13 +850,13 @@ Get models for a specific user configuration type:
```bash
# Get user provider models
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/user/providers/models
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/yourusername/providers/models
# Get user rotation models
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/user/rotations/models
# Get user rotation models
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/yourusername/rotations/models
# Get user autoselect models
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/user/autoselects/models
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/yourusername/autoselects/models
```
### Python Examples
......@@ -870,16 +870,16 @@ TOKEN = "YOUR_USER_TOKEN"
headers = {"Authorization": f"Bearer {TOKEN}"}
# List user models
response = requests.get(f"{BASE_URL}/api/user/models", headers=headers)
response = requests.get(f"{BASE_URL}/api/u/yourusername/models", headers=headers)
print(response.json())
# List user providers
response = requests.get(f"{BASE_URL}/api/user/providers", headers=headers)
response = requests.get(f"{BASE_URL}/api/u/yourusername/providers", headers=headers)
print(response.json())
# Send chat completion using user rotation
response = requests.post(
f"{BASE_URL}/api/user/chat/completions",
f"{BASE_URL}/api/u/yourusername/chat/completions",
headers=headers,
json={
"model": "user-rotation/myrotation",
......
......@@ -249,12 +249,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **User-Specific API Endpoints**: New API endpoints for authenticated users to access their own configurations
- `GET /api/user/models` - List user's own models
- `GET /api/user/providers` - List user's provider configurations
- `GET /api/user/rotations` - List user's rotation configurations
- `GET /api/user/autoselects` - List user's autoselect configurations
- `POST /api/user/chat/completions` - Chat completions using user's own models
- `GET /api/user/{config_type}/models` - List models for specific config type
- `GET /api/u/{username}/models` - List user's own models
- `GET /api/u/{username}/providers` - List user's provider configurations
- `GET /api/u/{username}/rotations` - List user's rotation configurations
- `GET /api/u/{username}/autoselects` - List user's autoselect configurations
- `POST /api/u/{username}/chat/completions` - Chat completions using user's own models
- `GET /api/u/{username}/{config_type}/models` - List models for specific config type
- Requires Bearer token or query parameter authentication
- Admin users get access to global + user configs, regular users get user-only configs
- Global tokens (in aisbf.json) have full access to all configurations
......
......@@ -131,9 +131,22 @@ pip install stem
Edit `/etc/tor/torrc` (or `~/.torrc` on macOS):
```
ControlPort 9051
CookieAuthentication 1
HashedControlPassword 16:YOUR_HASHED_PASSWORD_HERE
```
Generate a hashed password:
```bash
tor --hash-password "your_secure_password"
```
**For persistent hidden services, also add:**
```
HiddenServiceDir /home/yourusername/.aisbf/tor_hidden_service
HiddenServicePort 80 127.0.0.1:17765
```
**Important:** Replace `/home/yourusername` with your actual home directory path. Tor does NOT expand `~` in torrc - you must use absolute paths.
Restart TOR:
```bash
sudo systemctl restart tor # Linux
......@@ -151,8 +164,8 @@ TOR hidden service can be configured via the dashboard or configuration file.
"enabled": true,
"control_port": 9051,
"control_host": "127.0.0.1",
"control_password": null,
"hidden_service_dir": null,
"control_password": "your_secure_password",
"hidden_service_dir": "~/.aisbf/tor_hidden_service",
"hidden_service_port": 80,
"socks_port": 9050,
"socks_host": "127.0.0.1"
......@@ -160,6 +173,8 @@ TOR hidden service can be configured via the dashboard or configuration file.
}
```
**Important:** Set `control_password` to match the password you used when generating the HashedControlPassword for torrc.
**Configuration Options:**
- **`enabled`**: Enable/disable TOR hidden service (default: false)
......@@ -186,18 +201,27 @@ TOR hidden service can be configured via the dashboard or configuration file.
- Keys stored in specified directory
- Ideal for production use
- Set `hidden_service_dir` to a path (e.g., `~/.aisbf/tor_hidden_service`)
- **Must be manually configured in torrc** - see configuration instructions above
Example persistent configuration:
```json
{
"tor": {
"enabled": true,
"control_password": "your_secure_password",
"hidden_service_dir": "~/.aisbf/tor_hidden_service",
"hidden_service_port": 80
}
}
```
When you start aisbf with a persistent hidden service directory configured:
1. It checks if the hidden service already exists (looks for hostname file)
2. If found, it uses the existing onion address
3. If not found, it displays manual configuration instructions
4. You must add the HiddenServiceDir and HiddenServicePort to torrc manually
5. After configuring torrc and restarting Tor, start aisbf again
### Dashboard Configuration
1. Navigate to **Dashboard → Settings**
......@@ -258,9 +282,11 @@ All AISBF endpoints are available over TOR:
- Check AISBF logs for detailed error messages
**Authentication Failed:**
- Verify CookieAuthentication is enabled in torrc
- Check control_password matches torrc configuration
- Ensure AISBF has permission to read TOR cookie file
- Use password authentication instead of CookieAuthentication
- Generate hashed password: `tor --hash-password "your_password"`
- Add to torrc: `HashedControlPassword 16:YOUR_HASH`
- Set `control_password` in aisbf.json to match your password
- Restart Tor: `sudo systemctl restart tor`
**Onion Address Not Generated:**
- Check TOR logs: `journalctl -u tor` or `tail -f /var/log/tor/log`
......@@ -585,12 +611,12 @@ Authorization: Bearer YOUR_API_TOKEN
| Endpoint | Description |
|----------|-------------|
| `GET /api/user/models` | List available models from user's own configurations |
| `GET /api/user/providers` | List user's provider configurations |
| `GET /api/user/rotations` | List user's rotation configurations |
| `GET /api/user/autoselects` | List user's autoselect configurations |
| `POST /api/user/chat/completions` | Chat completions using user's own models |
| `GET /api/user/{config_type}/models` | List models for specific config type (provider, rotation, autoselect) |
| `GET /api/u/{username}/models` | List available models from user's own configurations |
| `GET /api/u/{username}/providers` | List user's provider configurations |
| `GET /api/u/{username}/rotations` | List user's rotation configurations |
| `GET /api/u/{username}/autoselects` | List user's autoselect configurations |
| `POST /api/u/{username}/chat/completions` | Chat completions using user's own models |
| `GET /api/u/{username}/{config_type}/models` | List models for specific config type (provider, rotation, autoselect) |
**Access Control:**
- **Admin Users** have access to both global and user configurations when using user API endpoints
......@@ -601,21 +627,28 @@ Authorization: Bearer YOUR_API_TOKEN
```bash
# List user models
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/user/models
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/yourusername/models
# Chat using user's own models
curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"model": "your-rotation/model", "messages": [{"role": "user", "content": "Hello"}]}' \
http://localhost:17765/api/user/chat/completions
http://localhost:17765/api/u/yourusername/chat/completions
```
### MCP (Model Context Protocol)
AISBF provides an MCP server for remote agent configuration and model access:
**Global MCP Endpoints (Admin-configured tokens):**
- **SSE Endpoint**: `GET /mcp` - Server-Sent Events for MCP communication
- **HTTP Endpoint**: `POST /mcp` - Direct HTTP transport for MCP
- **Tools**: `GET /mcp/tools` - List available MCP tools
- **Call Tool**: `POST /mcp/tools/call` - Call an MCP tool
**User-Specific MCP Endpoints (User API tokens):**
- **Tools**: `GET /mcp/u/{username}/tools` - List user's MCP tools
- **Call Tool**: `POST /mcp/u/{username}/tools/call` - Call user's MCP tool
MCP tools include:
- `list_models` - List available models (user or global depending on auth)
......
......@@ -2,7 +2,11 @@
A modular proxy server for managing multiple AI provider integrations with unified API interface. AISBF provides intelligent routing, load balancing, and AI-assisted model selection to optimize AI service usage across multiple providers.
![AISBF Dashboard](screenshot.png)
## Try AISBF
Try AISBF live at [https://aisbf.cloud](https://aisbf.cloud) or via TOR at [http://aisbfity4ud6nsht53tsh2iauaur2e4dah2gplcprnikyjpkg72vfjad.onion](http://aisbfity4ud6nsht53tsh2iauaur2e4dah2gplcprnikyjpkg72vfjad.onion) - no installation required!
![AISBF Dashboard](https://git.nexlab.net/nexlab/aisbf/raw/master/screenshot.png)
## Web Dashboard
......@@ -394,9 +398,22 @@ brew install tor
Edit `/etc/tor/torrc` (or `~/.torrc` on macOS) and add:
```
ControlPort 9051
CookieAuthentication 1
HashedControlPassword 16:YOUR_HASHED_PASSWORD_HERE
```
Generate a hashed password:
```bash
tor --hash-password "your_secure_password"
```
**For persistent hidden services, also add:**
```
HiddenServiceDir /home/yourusername/.aisbf/tor_hidden_service
HiddenServicePort 80 127.0.0.1:17765
```
**Important:** Replace `/home/yourusername` with your actual home directory path. Tor does NOT expand `~` in torrc - you must use absolute paths.
Then restart TOR:
```bash
sudo systemctl restart tor # Linux
......@@ -427,8 +444,8 @@ Edit `~/.aisbf/aisbf.json`:
"enabled": true,
"control_port": 9051,
"control_host": "127.0.0.1",
"control_password": null,
"hidden_service_dir": null,
"control_password": "your_secure_password",
"hidden_service_dir": "~/.aisbf/tor_hidden_service",
"hidden_service_port": 80,
"socks_port": 9050,
"socks_host": "127.0.0.1"
......@@ -436,6 +453,8 @@ Edit `~/.aisbf/aisbf.json`:
}
```
**Important:** Set `control_password` to match the password you used when generating the HashedControlPassword for torrc.
#### Ephemeral vs Persistent Hidden Services
**Ephemeral (Default):**
......@@ -451,6 +470,7 @@ Edit `~/.aisbf/aisbf.json`:
- Keys stored in specified directory
- Ideal for production use
- Set `hidden_service_dir` to a path (e.g., `~/.aisbf/tor_hidden_service`)
- **Must be manually configured in torrc** - see configuration instructions above
#### Accessing Your Hidden Service
......@@ -1040,15 +1060,7 @@ Authenticated users can access their configurations using their username in the
| `POST /api/u/{username}/chat/completions` | Chat completions using user's own models |
| `GET /api/u/{username}/{config_type}/models` | List models for specific config type (provider, rotation, autoselect) |
Legacy `/api/user/...` endpoints remain fully supported for backward compatibility:
| Legacy Endpoint | Description |
|-----------------|-------------|
| `GET /api/user/models` | Legacy endpoint for authenticated user |
| `GET /api/user/providers` | Legacy endpoint for authenticated user |
| `GET /api/user/rotations` | Legacy endpoint for authenticated user |
| `GET /api/user/autoselects` | Legacy endpoint for authenticated user |
| `POST /api/user/chat/completions` | Legacy chat completions endpoint |
| `GET /api/user/{config_type}/models` | Legacy model listing endpoint |
Legacy `/api/user/...` endpoints have been replaced with `/api/u/{username}/...` endpoints for better clarity and security.
#### Access Control
......@@ -1101,6 +1113,29 @@ All requests are properly authenticated and authorized using the standard Bearer
#### MCP Integration
User tokens also work with MCP (Model Context Protocol) endpoints:
**Global MCP Endpoints (Admin-configured tokens in aisbf.json):**
- `GET /mcp` - SSE endpoint for MCP communication
- `POST /mcp` - HTTP POST endpoint for MCP
- `GET /mcp/tools` - List available global MCP tools
- `POST /mcp/tools/call` - Call global MCP tools
**User-Specific MCP Endpoints (User API tokens):**
- `GET /mcp/u/{username}/tools` - List user's MCP tools
- `POST /mcp/u/{username}/tools/call` - Call user's MCP tools
Example:
```bash
# List user's MCP tools
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/mcp/u/johnsmith/tools
# Call a user MCP tool
curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "list_user_models", "arguments": {}}' \
http://localhost:17765/mcp/u/johnsmith/tools/call
```
- Admin users get access to both global and user-specific MCP tools
- Regular users get access to user-only MCP tools
- Tools include model access, configuration management, and usage statistics
......
# Tor Hidden Service Setup Guide for AISBF
This guide explains how to configure Tor for use with AISBF, including both ephemeral and persistent hidden services.
## Prerequisites
Install Tor and the stem Python library:
```bash
# Install Tor
sudo apt install tor # Debian/Ubuntu
sudo dnf install tor # Fedora
brew install tor # macOS
# Install stem library
pip install stem
```
## Tor Configuration (torrc)
### Basic Setup (Required for All Configurations)
Edit your torrc file (usually `/etc/tor/torrc` or `~/.torrc` on macOS):
```
ControlPort 9051
HashedControlPassword 16:YOUR_HASHED_PASSWORD_HERE
```
Generate a hashed password:
```bash
tor --hash-password "your_secure_password"
```
Copy the output (starts with `16:`) and paste it after `HashedControlPassword` in torrc.
### For Ephemeral Hidden Services (Default)
No additional torrc configuration needed. AISBF will create a temporary hidden service via the control port.
**AISBF Configuration (`~/.aisbf/aisbf.json`):**
```json
{
"tor": {
"enabled": true,
"control_password": "your_secure_password",
"hidden_service_dir": "",
"hidden_service_port": 80
}
}
```
### For Persistent Hidden Services (Production)
Add these lines to torrc:
```
HiddenServiceDir /home/yourusername/.aisbf/tor_hidden_service
HiddenServicePort 80 127.0.0.1:17765
```
**Important:**
- Replace `/home/yourusername` with your actual home directory path
- **Tor does NOT expand `~` in torrc** - you must use absolute paths like `/home/username`
- The port `17765` should match your AISBF server port
- The directory will be created by Tor with proper permissions
**AISBF Configuration (`~/.aisbf/aisbf.json`):**
```json
{
"tor": {
"enabled": true,
"control_password": "your_secure_password",
"hidden_service_dir": "/home/yourusername/.aisbf/tor_hidden_service",
"hidden_service_port": 80
}
}
```
**Note:** While AISBF config can use `~/.aisbf/tor_hidden_service`, the torrc file must use the full absolute path.
## Complete torrc Example
### For Ephemeral Hidden Service:
```
ControlPort 9051
HashedControlPassword 16:872860B76453A77D60CA2BB8C1A7042072093276A3D701AD684053EC4C
```
### For Persistent Hidden Service:
```
ControlPort 9051
HashedControlPassword 16:872860B76453A77D60CA2BB8C1A7042072093276A3D701AD684053EC4C
HiddenServiceDir /home/yourusername/.aisbf/tor_hidden_service
HiddenServicePort 80 127.0.0.1:17765
```
## Restart Tor
After editing torrc, restart Tor:
```bash
sudo systemctl restart tor # Linux
brew services restart tor # macOS
```
## Verify Tor is Running
```bash
# Check Tor status
sudo systemctl status tor # Linux
brew services list # macOS
# Test control port connection
telnet 127.0.0.1 9051
```
## Get Your Onion Address
### Ephemeral Service:
The onion address is displayed in AISBF logs when the service starts.
### Persistent Service:
```bash
cat ~/.aisbf/tor_hidden_service/hostname
```
## Troubleshooting
### Permission Denied on Cookie File
Use password authentication instead of cookie authentication (as shown above).
### Connection Refused
- Verify Tor is running: `sudo systemctl status tor`
- Check ControlPort is configured in torrc
- Test connection: `telnet 127.0.0.1 9051`
### Authentication Failed
- Verify the password in aisbf.json matches the one used to generate the hash
- Check the HashedControlPassword in torrc is correct
### Hidden Service Not Created
- Check Tor logs: `sudo journalctl -u tor -n 50`
- Verify HiddenServiceDir path is correct and accessible
- Ensure AISBF server port matches the one in HiddenServicePort
### Onion Address Not Accessible
- Verify AISBF is running: `curl http://localhost:17765`
- Check Tor Browser is configured correctly
- Wait a few minutes for the hidden service to propagate
## Security Considerations
1. **Use strong passwords** for Tor control authentication
2. **Enable API authentication** in AISBF for additional security
3. **Use persistent hidden services** for production deployments
4. **Monitor access logs** for suspicious activity
5. **Keep Tor and AISBF updated** regularly
6. **Consider firewall rules** to restrict clearnet access if only using Tor
## Additional Resources
- Tor Project Documentation: https://www.torproject.org/docs/
- Tor Hidden Service Guide: https://community.torproject.org/onion-services/
- stem Library Documentation: https://stem.torproject.org/
......@@ -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.21"
__version__ = "0.99.25"
__all__ = [
# Config
"config",
......
......@@ -231,7 +231,7 @@ class KiroAuthManager:
return self._access_token
async def _refresh_kiro_desktop_token(self):
"""Refresh token using Kiro Desktop Auth"""
"""Refresh token using Kiro Desktop Auth with retry logic"""
if not self.refresh_token:
raise ValueError("No refresh token available")
......@@ -242,25 +242,48 @@ class KiroAuthManager:
}
payload = {"refreshToken": self.refresh_token}
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload, headers=headers)
response.raise_for_status()
data = response.json()
self._access_token = data['accessToken']
if 'refreshToken' in data:
self.refresh_token = data['refreshToken']
self._refresh_token = data['refreshToken'] # Keep private token in sync
# Calculate expiration (1 hour default)
self._expires_at = datetime.now(timezone.utc) + timedelta(seconds=3600)
# Save if we have a credentials file
if self.creds_file:
self._save_credentials()
max_retries = 3
retry_delay = 1.0 # Start with 1 second
for attempt in range(max_retries):
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0)) as client:
response = await client.post(url, json=payload, headers=headers)
response.raise_for_status()
data = response.json()
self._access_token = data['accessToken']
if 'refreshToken' in data:
self.refresh_token = data['refreshToken']
self._refresh_token = data['refreshToken'] # Keep private token in sync
# Calculate expiration (1 hour default)
self._expires_at = datetime.now(timezone.utc) + timedelta(seconds=3600)
# Save if we have a credentials file
if self.creds_file:
self._save_credentials()
if attempt > 0:
logger.info(f"Token refresh succeeded on attempt {attempt + 1}")
return
except (httpx.ConnectTimeout, httpx.ReadTimeout, httpx.TimeoutException) as e:
logger.warning(f"Token refresh timeout on attempt {attempt + 1}/{max_retries}: {type(e).__name__}")
if attempt < max_retries - 1:
logger.info(f"Retrying in {retry_delay:.1f} seconds...")
await asyncio.sleep(retry_delay)
retry_delay *= 2 # Exponential backoff
else:
logger.error(f"Token refresh failed after {max_retries} attempts")
raise
except Exception as e:
logger.error(f"Token refresh failed with non-timeout error: {type(e).__name__}: {e}")
raise
async def _refresh_aws_sso_token(self):
"""Refresh token using AWS SSO OIDC"""
"""Refresh token using AWS SSO OIDC with retry logic"""
if not all([self.refresh_token, self.client_id, self.client_secret]):
raise ValueError("Missing credentials for AWS SSO OIDC")
......@@ -272,18 +295,41 @@ class KiroAuthManager:
"refresh_token": self.refresh_token
}
async with httpx.AsyncClient() as client:
response = await client.post(url, data=payload)
response.raise_for_status()
data = response.json()
self._access_token = data['access_token']
if 'refresh_token' in data:
self.refresh_token = data['refresh_token']
self._refresh_token = data['refresh_token'] # Keep private token in sync
expires_in = data.get('expires_in', 3600)
self._expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
max_retries = 3
retry_delay = 1.0 # Start with 1 second
for attempt in range(max_retries):
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0)) as client:
response = await client.post(url, data=payload)
response.raise_for_status()
data = response.json()
self._access_token = data['access_token']
if 'refresh_token' in data:
self.refresh_token = data['refresh_token']
self._refresh_token = data['refresh_token'] # Keep private token in sync
expires_in = data.get('expires_in', 3600)
self._expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
if attempt > 0:
logger.info(f"AWS SSO token refresh succeeded on attempt {attempt + 1}")
return
except (httpx.ConnectTimeout, httpx.ReadTimeout, httpx.TimeoutException) as e:
logger.warning(f"AWS SSO token refresh timeout on attempt {attempt + 1}/{max_retries}: {type(e).__name__}")
if attempt < max_retries - 1:
logger.info(f"Retrying in {retry_delay:.1f} seconds...")
await asyncio.sleep(retry_delay)
retry_delay *= 2 # Exponential backoff
else:
logger.error(f"AWS SSO token refresh failed after {max_retries} attempts")
raise
except Exception as e:
logger.error(f"AWS SSO token refresh failed with non-timeout error: {type(e).__name__}: {e}")
raise
def _save_credentials(self):
"""Save updated credentials to file"""
......
......@@ -249,6 +249,18 @@ class SMTPConfig(BaseModel):
from_email: str = ""
from_name: str = "AISBF"
class OAuth2ProviderConfig(BaseModel):
"""Configuration for an OAuth2 provider"""
enabled: bool = False
client_id: str = ""
client_secret: str = ""
scopes: List[str] = []
class OAuth2Config(BaseModel):
"""Configuration for OAuth2 authentication providers"""
google: Optional[OAuth2ProviderConfig] = None
github: Optional[OAuth2ProviderConfig] = None
class AISBFConfig(BaseModel):
"""Global AISBF configuration from aisbf.json"""
classify_nsfw: bool = False
......@@ -267,6 +279,7 @@ class AISBFConfig(BaseModel):
adaptive_rate_limiting: Optional[AdaptiveRateLimitingConfig] = None
signup: Optional[SignupConfig] = None
smtp: Optional[SMTPConfig] = None
oauth2: Optional[OAuth2Config] = None
currency: Optional[CurrencyConfig] = None
payment_gateways: Optional[Dict[str, PaymentGatewayConfig]] = None
......
......@@ -2406,6 +2406,105 @@ class DatabaseManager:
})
return transactions
def get_payment_gateway_settings(self) -> Dict:
"""Get payment gateway settings from admin_settings table."""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
default_gateways = {
"paypal": {"enabled": False, "client_id": "", "client_secret": "", "webhook_secret": "", "sandbox": True},
"stripe": {"enabled": False, "publishable_key": "", "secret_key": "", "webhook_secret": "", "test_mode": True},
"bitcoin": {"enabled": False, "address": "", "confirmations": 3, "expiration_minutes": 120},
"ethereum": {"enabled": False, "address": "", "confirmations": 12, "chain_id": 1},
"usdt": {"enabled": False, "address": "", "network": "erc20", "confirmations": 3},
"usdc": {"enabled": False, "address": "", "network": "erc20", "confirmations": 3}
}
try:
cursor.execute(f'''
SELECT setting_value
FROM admin_settings
WHERE setting_key = {placeholder}
''', ('payment_gateways',))
row = cursor.fetchone()
if row and row[0]:
import json
return json.loads(row[0])
except Exception as e:
logger.warning(f"Error loading payment gateway settings: {e}")
return default_gateways
def save_payment_gateway_settings(self, settings: Dict) -> bool:
"""Save payment gateway settings to admin_settings table."""
with self._get_connection() as conn:
cursor = conn.cursor()
import json
settings_json = json.dumps(settings)
placeholder = '?' if self.db_type == 'sqlite' else '%s'
try:
insert_syntax = 'INSERT OR REPLACE' if self.db_type == 'sqlite' else 'REPLACE'
cursor.execute(f'''
{insert_syntax} INTO admin_settings (setting_key, setting_value, updated_at)
VALUES ({placeholder}, {placeholder}, CURRENT_TIMESTAMP)
''', ('payment_gateways', settings_json))
conn.commit()
logger.info("Payment gateway settings saved to database")
return True
except Exception as e:
logger.error(f"Error saving payment gateway settings: {e}")
return False
def get_currency_settings(self) -> Dict:
"""Get currency settings from admin_settings table."""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
default_currency = {
"currency_code": "USD",
"currency_symbol": "$",
"currency_decimals": 2
}
try:
cursor.execute(f'''
SELECT setting_value
FROM admin_settings
WHERE setting_key = {placeholder}
''', ('currency',))
row = cursor.fetchone()
if row and row[0]:
import json
return json.loads(row[0])
except Exception as e:
logger.warning(f"Error loading currency settings: {e}")
return default_currency
def save_currency_settings(self, settings: Dict) -> bool:
"""Save currency settings to admin_settings table."""
with self._get_connection() as conn:
cursor = conn.cursor()
import json
settings_json = json.dumps(settings)
placeholder = '?' if self.db_type == 'sqlite' else '%s'
try:
insert_syntax = 'INSERT OR REPLACE' if self.db_type == 'sqlite' else 'REPLACE'
cursor.execute(f'''
{insert_syntax} INTO admin_settings (setting_key, setting_value, updated_at)
VALUES ({placeholder}, {placeholder}, CURRENT_TIMESTAMP)
''', ('currency', settings_json))
conn.commit()
logger.info("Currency settings saved to database")
return True
except Exception as e:
logger.error(f"Error saving currency settings: {e}")
return False
# ============================================================
# DATABASE REGISTRY - EXPLICIT NAMED INSTANCES
......@@ -2909,34 +3008,6 @@ def DatabaseManager__initialize_database(self):
''')
conn.commit()
logger.info("✅ Migration: Created missing account_tiers table")
else:
cursor.execute("""
SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_NAME = 'account_tiers'
""")
if not cursor.fetchone():
cursor.execute(f'''
CREATE TABLE account_tiers (
id INTEGER PRIMARY KEY {auto_increment},
name VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
price_monthly DECIMAL(10,2) DEFAULT 0.00,
price_yearly DECIMAL(10,2) DEFAULT 0.00,
is_default {boolean_type} DEFAULT 0,
is_active {boolean_type} DEFAULT 1,
max_requests_per_day INTEGER DEFAULT -1,
max_requests_per_month INTEGER DEFAULT -1,
max_providers INTEGER DEFAULT -1,
max_rotations INTEGER DEFAULT -1,
max_autoselections INTEGER DEFAULT -1,
max_rotation_models INTEGER DEFAULT -1,
max_autoselection_models INTEGER DEFAULT -1,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default}
)
''')
conn.commit()
logger.info("✅ Migration: Created missing account_tiers table")
except Exception as e:
logger.warning(f"Migration check for account_tiers table: {e}")
......@@ -3091,6 +3162,14 @@ def DatabaseManager__initialize_database(self):
FOREIGN KEY (user_id) REFERENCES users(id)
)
'''),
('admin_settings', f'''
CREATE TABLE admin_settings (
id INTEGER PRIMARY KEY {auto_increment},
setting_key VARCHAR(255) UNIQUE NOT NULL,
setting_value TEXT,
updated_at TIMESTAMP DEFAULT {timestamp_default}
)
'''),
('user_subscriptions', f'''
CREATE TABLE user_subscriptions (
id INTEGER PRIMARY KEY {auto_increment},
......
......@@ -39,6 +39,7 @@ class MCPAuthLevel:
NONE = 0
AUTOSELECT = 1 # Can access autoselection and autorotation settings
FULLCONFIG = 2 # Can access all configurations (providers, rotations, autoselect, aisbf)
USER = 3 # User-specific access via API token
def load_mcp_config() -> Dict:
......@@ -1597,13 +1598,19 @@ class MCPServer:
# Route to appropriate handler
from starlette.requests import Request
from main import get_user_handler
# Get username for the path
from .database import get_database
db = get_database()
user = db.get_user_by_id(user_id)
username = user['username'] if user else 'unknown'
scope = {
"type": "http",
"method": "POST",
"headers": [],
"query_string": b"",
"path": "/api/user/chat/completions"
"path": f"/api/u/{username}/chat/completions"
}
dummy_request = Request(scope)
......
......@@ -98,6 +98,7 @@ class AccountTier(BaseModel):
price_yearly: float = 0.0
is_default: bool = False
is_active: bool = True
is_visible: bool = True # If False, tier is hidden from user selection but can be assigned by admin
# Limits
max_requests_per_day: int = -1
......
......@@ -127,8 +127,28 @@ class KiroProviderHandler(BaseProviderHandler):
# Apply rate limiting
await self.apply_rate_limit()
# Get access token and profile ARN
access_token = await self.auth_manager.get_access_token()
# Get access token and profile ARN with retry logic
max_retries = 3
retry_delay = 1.0
access_token = None
for attempt in range(max_retries):
try:
access_token = await self.auth_manager.get_access_token()
break
except (Exception) as e:
if "ConnectTimeout" in str(type(e).__name__) or "TimeoutException" in str(type(e).__name__):
logging.warning(f"Token retrieval timeout on attempt {attempt + 1}/{max_retries}")
if attempt < max_retries - 1:
logging.info(f"Retrying in {retry_delay:.1f} seconds...")
await asyncio.sleep(retry_delay)
retry_delay *= 2
else:
logging.error(f"Token retrieval failed after {max_retries} attempts")
raise
else:
raise
profile_arn = self.auth_manager.profile_arn
# Determine effective profileArn based on auth type
......@@ -176,12 +196,30 @@ class KiroProviderHandler(BaseProviderHandler):
model=model
)
# Non-streaming request
response = await self.client.post(
kiro_api_url,
json=payload,
headers=headers
)
# Non-streaming request with retry logic
max_api_retries = 2
api_retry_delay = 2.0
response = None
for api_attempt in range(max_api_retries):
try:
response = await self.client.post(
kiro_api_url,
json=payload,
headers=headers
)
break
except (httpx.ConnectTimeout, httpx.ReadTimeout, httpx.TimeoutException) as e:
logging.warning(f"API request timeout on attempt {api_attempt + 1}/{max_api_retries}: {type(e).__name__}")
if api_attempt < max_api_retries - 1:
logging.info(f"Retrying API request in {api_retry_delay:.1f} seconds...")
await asyncio.sleep(api_retry_delay)
api_retry_delay *= 1.5
else:
logging.error(f"API request failed after {max_api_retries} attempts")
self.record_failure()
raise
# Check for 429 rate limit error before raising
if response.status_code == 429:
......@@ -234,6 +272,11 @@ class KiroProviderHandler(BaseProviderHandler):
self.record_success()
return openai_response
except (httpx.ConnectTimeout, httpx.ReadTimeout, httpx.TimeoutException) as e:
logging.error(f"KiroProviderHandler: Timeout error after retries: {type(e).__name__}")
logging.debug(f"KiroProviderHandler: Timeout details", exc_info=True)
self.record_failure()
raise Exception(f"Kiro API timeout after retries: {type(e).__name__}")
except Exception as e:
logging.error(f"KiroProviderHandler: Error: {str(e)}", exc_info=True)
self.record_failure()
......
......@@ -84,7 +84,7 @@ class TorHiddenService:
)
# Authenticate
if self.config.control_password:
if self.config.control_password and self.config.control_password.strip():
logger.info("Authenticating with TOR control port using password")
self.controller.authenticate(password=self.config.control_password)
else:
......@@ -106,6 +106,50 @@ class TorHiddenService:
logger.error(f"Error connecting to TOR: {e}")
return False
def _check_existing_hidden_service(self, hidden_service_dir: Path) -> Optional[str]:
"""
Check if a hidden service already exists in torrc and retrieve its onion address.
Args:
hidden_service_dir: Path to the hidden service directory
Returns:
str: Onion address if found, None otherwise
"""
try:
hostname_file = hidden_service_dir / 'hostname'
if hostname_file.exists() and hostname_file.stat().st_size > 0:
onion_address = hostname_file.read_text().strip()
logger.info(f"Found existing hidden service: {onion_address}")
return onion_address
except Exception as e:
logger.debug(f"Could not read existing hostname file: {e}")
return None
def _print_manual_configuration_instructions(self, hidden_service_dir: Path, local_port: int):
"""
Print manual configuration instructions for persistent hidden service.
Args:
hidden_service_dir: Path to the hidden service directory
local_port: Local port where AISBF is running
"""
logger.error("=" * 80)
logger.error("MANUAL CONFIGURATION REQUIRED")
logger.error("=" * 80)
logger.error("To set up a persistent Tor hidden service, add the following lines")
logger.error("to your torrc file (usually /etc/tor/torrc):")
logger.error("")
logger.error(f" HiddenServiceDir {hidden_service_dir}")
logger.error(f" HiddenServicePort {self.config.hidden_service_port} 127.0.0.1:{local_port}")
logger.error("")
logger.error("Then restart Tor:")
logger.error(" sudo systemctl restart tor")
logger.error("")
logger.error("After Tor restarts, your onion address will be in:")
logger.error(f" {hidden_service_dir}/hostname")
logger.error("=" * 80)
def create_hidden_service(self, local_port: int) -> Optional[str]:
"""
Create a TOR hidden service.
......@@ -122,38 +166,44 @@ class TorHiddenService:
try:
from stem.control import Controller
import time
# Determine if we should use persistent or ephemeral hidden service
if self.config.hidden_service_dir:
if self.config.hidden_service_dir and self.config.hidden_service_dir.strip():
# Persistent hidden service
hidden_service_dir = Path(self.config.hidden_service_dir).expanduser()
hidden_service_dir = Path(self.config.hidden_service_dir).expanduser().resolve()
hidden_service_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Creating persistent hidden service in {hidden_service_dir}")
logger.info(f"Setting up persistent hidden service in {hidden_service_dir}")
# Check if hidden service already exists
existing_address = self._check_existing_hidden_service(hidden_service_dir)
if existing_address:
self.onion_address = existing_address
logger.info(f"Using existing persistent hidden service: {self.onion_address}")
return self.onion_address
# Persistent hidden services must be configured in torrc
logger.warning("Persistent hidden service not found")
self._print_manual_configuration_instructions(hidden_service_dir, local_port)
# Set hidden service configuration
self.controller.set_options([
('HiddenServiceDir', str(hidden_service_dir)),
('HiddenServicePort', f'{self.config.hidden_service_port} 127.0.0.1:{local_port}')
])
# Wait a bit to see if user configured it
logger.info("Waiting 10 seconds for manual configuration...")
logger.info("(Configure torrc now, or press Ctrl+C to cancel)")
# 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
for attempt in range(10):
time.sleep(1)
logger.debug(f"Waiting for hostname file... attempt {attempt + 1}/30")
if hostname_file.exists() and hostname_file.stat().st_size > 0:
self.onion_address = hostname_file.read_text().strip()
logger.info(f"Persistent hidden service detected: {self.onion_address}")
return self.onion_address
if hostname_file.exists():
self.onion_address = hostname_file.read_text().strip()
logger.info(f"Persistent hidden service created: {self.onion_address}")
else:
logger.error("Failed to read onion address from hostname file")
return None
logger.error("Persistent hidden service not configured")
logger.error("Please configure torrc manually and restart aisbf")
return None
else:
# Ephemeral hidden service
logger.info("Creating ephemeral hidden service")
......
......@@ -83,8 +83,8 @@
"enabled": false,
"control_port": 9051,
"control_host": "127.0.0.1",
"control_password": null,
"hidden_service_dir": null,
"control_password": "",
"hidden_service_dir": "",
"hidden_service_port": 80,
"socks_port": 9050,
"socks_host": "127.0.0.1"
......
# Username and Display Name Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement separate username and display_name fields to handle OAuth signup with full names while maintaining clean usernames for system use.
**Architecture:** Add display_name column to users table, update user creation and retrieval functions, modify OAuth callbacks to generate usernames from display names with email fallback, and update UI to display display_name where appropriate.
**Tech Stack:** Python, SQLite/MySQL, FastAPI, Jinja2 templates
---
## File Structure
**Modified Files:**
- `aisbf/database.py`: Add display_name column, update user CRUD operations
- `main.py`: Update OAuth callback handlers and user creation endpoints
- `templates/dashboard/users.html`: Display display_name in user lists
- `templates/dashboard/profile.html`: Allow display_name editing
- `templates/dashboard/signup.html`: Handle display_name in signup flow
**New Files:**
- `docs/superpowers/plans/2026-04-15-username-display-name-plan.md`: This plan document
---
### Task 1: Database Schema Migration
**Files:**
- Modify: `aisbf/database.py`
- [ ] **Step 1: Add display_name column to users table**
In `database.py`, find the users table creation in `init_database()` around line 2620. Add the display_name column:
```python
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY {auto_increment},
username VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE,
display_name VARCHAR(255),
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'user',
created_by VARCHAR(255),
created_at TIMESTAMP DEFAULT {timestamp_default},
last_login TIMESTAMP NULL,
is_active {boolean_type} DEFAULT 1,
email_verified {boolean_type} DEFAULT 0,
verification_token VARCHAR(255),
verification_token_expires TIMESTAMP NULL,
last_verification_email_sent TIMESTAMP NULL
)
''')
```
- [ ] **Step 2: Add migration for existing users**
After table creation, add migration logic to populate display_name for existing users:
```python
# Migration for existing users
cursor.execute("UPDATE users SET display_name = username WHERE display_name IS NULL")
```
- [ ] **Step 3: Update user retrieval functions**
Update `get_user_by_id()`, `get_user_by_username()`, `get_user_by_email()` to include display_name in returned dict.
For example, in `get_user_by_id()`:
```python
cursor.execute('''
SELECT id, username, email, display_name, role, is_active, email_verified, created_at, last_verification_email_sent
FROM users
WHERE id = ?
''', (user_id,))
```
And in the result processing:
```python
if row:
return {
'id': row[0],
'username': row[1],
'email': row[2],
'display_name': row[3] or row[1], # Default to username if display_name empty
'role': row[4],
'is_active': row[5],
'email_verified': row[6],
'created_at': row[7],
'last_verification_email_sent': row[8]
}
```
- [ ] **Step 4: Update create_user function**
Modify `create_user()` to accept display_name parameter:
```python
def create_user(self, username: str, password_hash: str, role: str = 'user', created_by: str = None,
email: str = None, email_verified: bool = False, display_name: str = None):
```
In the INSERT statement:
```python
INSERT INTO users (username, email, password_hash, role, created_by, email_verified, display_name)
VALUES (?, ?, ?, ?, ?, ?, ?)
```
And parameters:
```python
(username, email, password_hash, role, created_by, 1 if email_verified else 0, display_name or username)
```
- [ ] **Step 5: Update user update functions**
Modify `update_user()` and `update_user_profile()` to handle display_name:
```python
def update_user(self, user_id: int, username: str, password_hash: str = None, role: str = 'user',
is_active: bool = True, display_name: str = None):
```
In the UPDATE query:
```python
UPDATE users SET username = ?, password_hash = ?, role = ?, is_active = ?, display_name = ? WHERE id = ?
```
- [ ] **Step 6: Test database changes**
Run the application and check that users table has display_name column and existing users have it populated.
- [ ] **Step 7: Commit database changes**
```bash
git add aisbf/database.py
git commit -m "feat: add display_name column to users table"
```
### Task 2: Username Sanitization Utility
**Files:**
- Modify: `aisbf/database.py`
- [ ] **Step 1: Add sanitize_username function**
Add a utility function to sanitize usernames:
```python
def sanitize_username(self, input_str: str) -> str:
"""Sanitize string to valid username format."""
if not input_str:
return ""
# Lowercase
result = input_str.lower()
# Replace spaces with underscores
result = result.replace(" ", "_")
# Remove invalid characters (keep a-z, 0-9, -, _, .)
import re
result = re.sub(r'[^a-z0-9\-_.]', '', result)
# Trim and ensure length
result = result.strip("._-")
if len(result) < 3:
return ""
if len(result) > 50:
result = result[:50].rstrip("._-")
return result
```
- [ ] **Step 2: Add generate_username_from_display_name function**
Add function to generate username from display_name with email fallback:
```python
def generate_username_from_display_name(self, display_name: str, email: str) -> str:
"""Generate clean username from display_name, fallback to email."""
# Try display_name first
if display_name and display_name.strip():
username_base = self.sanitize_username(display_name)
if username_base:
return username_base
# Fallback to email prefix
if email and '@' in email:
email_prefix = email.split('@')[0]
username_base = self.sanitize_username(email_prefix)
if username_base:
return username_base
# Final fallback
return "user"
```
- [ ] **Step 3: Add find_unique_username function**
Add function to ensure username uniqueness:
```python
def find_unique_username(self, base_username: str) -> str:
"""Find a unique username, appending counter if needed."""
username = base_username
counter = 1
while self.get_user_by_username(username):
username = f"{base_username}{counter}"
counter += 1
if counter > 100: # Prevent infinite loop
raise ValueError("Could not generate unique username")
return username
```
- [ ] **Step 4: Test sanitization functions**
Create a simple test to verify sanitization works:
```python
# Test in Python REPL
db = Database()
print(db.sanitize_username('Stefy "nextime" Lanza')) # Should output: 'stefy_nextime_lanza'
print(db.generate_username_from_display_name('Stefy "nextime" Lanza', 'test@example.com')) # Should output: 'stefy_nextime_lanza'
```
- [ ] **Step 5: Commit utility functions**
```bash
git add aisbf/database.py
git commit -m "feat: add username sanitization and generation utilities"
```
### Task 3: Update OAuth Google Callback
**Files:**
- Modify: `main.py`
- [ ] **Step 1: Update Google OAuth callback logic**
In `oauth2_google_callback()` around line 2950, modify username generation:
Replace:
```python
google_username = user_info.get('name') or email.split('@')[0]
```
With:
```python
display_name = user_info.get('name', '')
google_username = db.generate_username_from_display_name(display_name, email)
google_username = db.find_unique_username(google_username)
```
- [ ] **Step 2: Update user creation call**
In the user creation:
```python
user_id = db.create_user(final_username, password_hash, 'user', None, email, True)
```
Change to:
```python
user_id = db.create_user(google_username, password_hash, 'user', None, email, True, display_name)
```
- [ ] **Step 3: Update session setting**
```python
request.session['username'] = google_username
```
- [ ] **Step 4: Test Google OAuth signup**
Set up Google OAuth and test signup with a display name containing spaces/quotes.
- [ ] **Step 5: Commit Google OAuth changes**
```bash
git add main.py
git commit -m "feat: update Google OAuth to use display_name for username generation"
```
### Task 4: Update OAuth GitHub Callback
**Files:**
- Modify: `main.py`
- [ ] **Step 1: Update GitHub OAuth callback logic**
In `oauth2_github_callback()` around line 3100, modify username generation:
Replace:
```python
github_username = user_info.get('login') or user_info.get('name') or email.split('@')[0]
```
With:
```python
display_name = user_info.get('name', '') or user_info.get('login', '')
github_username = db.generate_username_from_display_name(display_name, email)
github_username = db.find_unique_username(github_username)
```
- [ ] **Step 2: Update user creation call**
In the user creation:
```python
user_id = db.create_user(final_username, password_hash, 'user', None, email, True)
```
Change to:
```python
user_id = db.create_user(github_username, password_hash, 'user', None, email, True, display_name)
```
- [ ] **Step 3: Update session setting**
```python
request.session['username'] = github_username
```
- [ ] **Step 4: Test GitHub OAuth signup**
Set up GitHub OAuth and test signup with a display name containing spaces.
- [ ] **Step 5: Commit GitHub OAuth changes**
```bash
git add main.py
git commit -m "feat: update GitHub OAuth to use display_name for username generation"
```
### Task 5: Update User Management Endpoints
**Files:**
- Modify: `main.py`
- [ ] **Step 1: Update admin user creation**
In `dashboard_users_add()` around line 4460, update create_user call:
```python
user_id = db.create_user(username, password_hash, role, admin_username, email, False, username)
```
The last parameter is display_name, defaulting to username.
- [ ] **Step 2: Update admin user editing**
In `dashboard_users_edit()` around line 4490, update update_user call:
```python
db.update_user(user_id, username, password_hash if password else None, role, is_active, username)
```
- [ ] **Step 3: Update user profile endpoint**
In the profile update endpoint around line 2554, update to handle display_name:
Add display_name parameter to form and update call:
```python
display_name: str = Form("")
# ...
db.update_user_profile(user_id, username, display_name)
```
And update the update_user_profile function in database.py to accept display_name.
- [ ] **Step 4: Update signup form handling**
In signup endpoint, ensure display_name is set to username initially.
- [ ] **Step 5: Test user management**
Test creating/editing users via admin panel and profile settings.
- [ ] **Step 6: Commit user management changes**
```bash
git add main.py aisbf/database.py
git commit -m "feat: update user management endpoints to handle display_name"
```
### Task 6: Update UI Templates
**Files:**
- Modify: `templates/dashboard/users.html`
- Modify: `templates/dashboard/profile.html`
- [ ] **Step 1: Update users list to show display_name**
In `users.html`, change username display to display_name where appropriate:
```html
<td>{{ user.display_name or user.username }}</td>
```
- [ ] **Step 2: Update profile template to allow display_name editing**
In `profile.html`, add display_name field:
```html
<label for="display_name">Display Name</label>
<input type="text" id="display_name" name="display_name" value="{{ user.display_name or user.username }}">
```
- [ ] **Step 3: Test UI changes**
Load user list and profile pages, verify display_name shows and can be edited.
- [ ] **Step 4: Commit UI changes**
```bash
git add templates/dashboard/users.html templates/dashboard/profile.html
git commit -m "feat: update UI to display and edit display_name"
```
### Task 7: Integration Testing
**Files:**
- N/A (manual testing)
- [ ] **Step 1: Test OAuth signup with various names**
Test Google/GitHub OAuth with names like:
- "John Doe"
- "Mary Jane Watson-Smith"
- "Dr. Strange"
- Names with quotes, apostrophes, etc.
- [ ] **Step 2: Test username conflicts**
Create users with similar names and verify counters work.
- [ ] **Step 3: Test admin user management**
Create/edit users via admin panel, verify display_name handling.
- [ ] **Step 4: Test profile editing**
Edit display_name in user profile, verify it saves and displays correctly.
- [ ] **Step 5: Test backward compatibility**
Verify existing users without display_name work correctly (should default to username).
- [ ] **Step 6: Final commit**
```bash
git commit -m "feat: complete username and display_name implementation"
```
\ No newline at end of file
# Username and Display Name Handling Design
## Overview
Implement separate username and display_name fields to handle OAuth signup with full names while maintaining clean usernames for system use.
## Problem Statement
Google OAuth returns display names like "Stefy "nextime" Lanza" which contain spaces and special characters not allowed in usernames. Current system tries to use raw display name as username, causing validation failures.
## Solution
- Add `display_name` VARCHAR(255) column to users table
- Generate clean username from display_name (with email fallback)
- Store raw OAuth display name in display_name field
- Use display_name for UI display, username for system identification
## Database Schema Changes
### Users Table
```sql
ALTER TABLE users ADD COLUMN display_name VARCHAR(255);
UPDATE users SET display_name = username WHERE display_name IS NULL;
```
### Affected Queries
- All SELECT from users: include display_name
- INSERT users: accept display_name parameter
- UPDATE users: allow display_name changes
## Username Generation Logic
### OAuth Signup Flow
1. Get display_name from OAuth provider:
- Google: `user_info.get('name')`
- GitHub: `user_info.get('name')` or `user_info.get('login')`
2. Generate username:
```python
if display_name and display_name.strip():
# Sanitize display_name
username_base = sanitize(display_name)
else:
# Fallback to email
username_base = sanitize(email.split('@')[0])
# Ensure unique
username = find_unique_username(username_base)
```
3. Sanitization rules:
- Lowercase
- Remove invalid characters (keep a-z, A-Z, 0-9, -, _, .)
- Replace spaces with underscores
- Length 3-50 characters
- Trim whitespace
4. Uniqueness handling:
- Check if username exists
- If conflict, append counter: username1, username2, etc.
## Code Changes Required
### Database Layer (database.py)
- Update table creation/migration
- Update user creation functions
- Update user retrieval functions
### OAuth Handlers (main.py)
- Update Google OAuth callback
- Update GitHub OAuth callback
- Pass display_name to user creation
### User Management
- Update admin user creation
- Update user profile editing
- Update signup form handling
### UI Changes
- Display display_name in user lists
- Allow display_name editing in profile
- Show username in technical contexts
## Migration Strategy
1. Add display_name column with NULL allowed initially
2. Populate existing users: display_name = username
3. Make display_name NOT NULL with default ''
4. Update all code to handle display_name
5. Test OAuth signup with display names
## Testing Requirements
- OAuth signup with names containing spaces/quotes
- Username conflict resolution
- Display name editing
- Backward compatibility with existing users
- UI displays display_name correctly
## Success Criteria
- OAuth signup works with any display name format
- Usernames are always valid and unique
- Display names preserve user identity
- Existing functionality unchanged
- UI shows appropriate names in context</content>
<parameter name="filePath">docs/superpowers/specs/2026-04-15-username-display-name-design.md
\ No newline at end of file
......@@ -24,6 +24,7 @@ Main application for AISBF.
"""
from typing import Optional
from fastapi import FastAPI, HTTPException, Request, status, Form, Query, UploadFile, File
from aisbf import __version__
from fastapi.responses import JSONResponse, StreamingResponse, HTMLResponse, RedirectResponse, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.exceptions import RequestValidationError
......@@ -493,6 +494,7 @@ def setup_template_globals():
"""Setup Jinja2 template globals for proxy-aware URLs"""
templates.env.globals['url_for'] = url_for
templates.env.globals['get_base_url'] = get_base_url
templates.env.globals['__version__'] = __version__
# Add md5 filter for Gravatar email hashing (handles None/empty values gracefully)
def md5_filter(s):
if not s:
......@@ -1312,6 +1314,22 @@ async def auth_middleware(request: Request, call_next):
except Exception as e:
logger.error(f"Error checking email verification status for user {user_id}: {e}")
# Check if user still exists (handle case where user was deleted by admin)
# This ensures deleted users are logged out on their next request
user_id = request.session.get('user_id')
if user_id:
try:
from aisbf.database import get_database
db = DatabaseRegistry.get_config_database()
current_user = db.get_user_by_id(user_id)
if not current_user:
# User has been deleted, log them out
logger.info(f"User {user_id} has been deleted, logging out session")
request.session.clear()
return RedirectResponse(url=url_for(request, "/dashboard/login") + "?error=Your account has been deleted. Please contact an administrator.", status_code=303)
except Exception as e:
logger.error(f"Error checking user existence for user {user_id}: {e}")
# Only check email_verified if verification is required
if require_verification and not request.session.get('email_verified'):
# Allow only specific routes for unverified users
......@@ -2496,53 +2514,24 @@ async def dashboard_logout(request: Request):
@app.get("/dashboard/profile", response_class=HTMLResponse)
async def dashboard_profile(request: Request):
"""User profile edit page"""
"""User profile page"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
# User dashboard - load usage stats same as main dashboard user route
from aisbf.database import get_database
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']) >= 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 user data for profile
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')
}
......@@ -3198,6 +3187,7 @@ async def dashboard_index(request: Request):
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,
......@@ -3248,6 +3238,7 @@ async def dashboard_index(request: Request):
context={
"request": request,
"session": request.session,
"__version__": __version__,
"usage_stats": usage_stats,
"providers_count": providers_count,
"rotations_count": rotations_count,
......@@ -3302,6 +3293,7 @@ async def dashboard_providers(request: Request):
context={
"request": request,
"session": request.session,
"__version__": __version__,
"providers_json": json.dumps(providers_data),
"success": "Configuration saved successfully! Restart server for changes to take effect." if success else None
}
......@@ -3719,6 +3711,7 @@ async def dashboard_rotations_save(request: Request, config: str = Form(...)):
context={
"request": request,
"session": request.session,
"__version__": __version__,
"rotations_json": json.dumps(rotations_data),
"available_providers": json.dumps(available_providers),
"success": "Configuration saved successfully! Restart server for changes to take effect."
......@@ -3878,6 +3871,7 @@ async def dashboard_autoselect_save(request: Request, config: str = Form(...)):
context={
"request": request,
"session": request.session,
"__version__": __version__,
"autoselect_json": json.dumps(autoselect_data),
"available_rotations": json.dumps(available_rotations),
"success": "Configuration saved successfully! Restart server for changes to take effect."
......@@ -4145,7 +4139,9 @@ async def dashboard_settings(request: Request):
context={
"request": request,
"session": request.session,
"config": aisbf_config
"__version__": __version__,
"config": aisbf_config,
"os": os
}
)
......@@ -5505,6 +5501,7 @@ async def dashboard_user_tokens(request: Request):
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_tokens": user_tokens,
"user_id": user_id
}
......@@ -5860,13 +5857,13 @@ async def api_get_currency_settings(request: Request):
if auth_check:
return auth_check
# Return default currency settings
# These could be stored in database or config file in the future
return JSONResponse({
"currency_code": "USD",
"currency_symbol": "$",
"currency_decimals": 2
})
from aisbf.database import get_database
db = DatabaseRegistry.get_config_database()
# Get currency settings from database
settings = db.get_currency_settings()
return JSONResponse(settings)
@app.post("/api/admin/settings/currency")
async def api_save_currency_settings(request: Request):
......@@ -5877,8 +5874,13 @@ async def api_save_currency_settings(request: Request):
try:
body = await request.json()
# In the future, save to database or config file
# For now, just return success
from aisbf.database import get_database
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}")
......@@ -5892,16 +5894,13 @@ async def api_get_payment_gateways(request: Request):
if auth_check:
return auth_check
# Return default/empty payment gateway settings
# These could be stored in database or config file in the future
return JSONResponse({
"paypal": {"enabled": False, "client_id": "", "client_secret": "", "webhook_secret": "", "sandbox": True},
"stripe": {"enabled": False, "publishable_key": "", "secret_key": "", "webhook_secret": "", "sandbox": True},
"bitcoin": {"enabled": False, "address": "", "confirmations": 3, "expiration_minutes": 120},
"ethereum": {"enabled": False, "address": "", "confirmations": 12, "chain_id": 1},
"usdt": {"enabled": False, "address": "", "network": "erc20", "confirmations": 3},
"usdc": {"enabled": False, "address": "", "network": "erc20", "confirmations": 3}
})
from aisbf.database import get_database
db = DatabaseRegistry.get_config_database()
# Get payment gateway settings from database
gateways = db.get_payment_gateway_settings()
return JSONResponse(gateways)
@app.post("/api/admin/settings/payment-gateways")
async def api_save_payment_gateways(request: Request):
......@@ -5912,8 +5911,13 @@ async def api_save_payment_gateways(request: Request):
try:
body = await request.json()
# In the future, save to database or config file
# For now, just return success
from aisbf.database import get_database
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}")
......@@ -5936,16 +5940,16 @@ async def dashboard_pricing(request: Request):
# Get enabled payment gateways
enabled_gateways = []
from aisbf.config import config
if config.aisbf and hasattr(config.aisbf, 'payment_gateways') and config.aisbf.payment_gateways:
for gateway, settings in config.aisbf.payment_gateways.items():
if settings.get('enabled', False):
enabled_gateways.append(gateway)
from aisbf.database import get_database
db = DatabaseRegistry.get_config_database()
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_symbol = "$"
if config.aisbf and config.aisbf.currency:
currency_symbol = config.aisbf.currency.get('symbol', "$")
currency_settings = db.get_currency_settings()
currency_symbol = currency_settings.get('currency_symbol', '$')
return templates.TemplateResponse(
request=request,
......@@ -5978,16 +5982,14 @@ async def dashboard_subscription(request: Request):
# Get enabled payment gateways
enabled_gateways = []
from aisbf.config import config
if config.aisbf and hasattr(config.aisbf, 'payment_gateways') and config.aisbf.payment_gateways:
for gateway, settings in config.aisbf.payment_gateways.items():
if settings.get('enabled', False):
enabled_gateways.append(gateway)
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_symbol = "$"
if config.aisbf and config.aisbf.currency:
currency_symbol = config.aisbf.currency.get('symbol', "$")
currency_settings = db.get_currency_settings()
currency_symbol = currency_settings.get('currency_symbol', '$')
return templates.TemplateResponse(
request=request,
......@@ -6016,13 +6018,21 @@ async def dashboard_billing(request: Request):
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)
return templates.TemplateResponse(
request=request,
name="dashboard/billing.html",
context={
"request": request,
"session": request.session,
"transactions": transactions
"transactions": transactions,
"enabled_gateways": enabled_gateways
}
)
......@@ -7871,6 +7881,98 @@ async def mcp_post(request: Request):
)
@app.get("/mcp/u/{username}/tools")
async def mcp_user_list_tools(request: Request, username: str):
"""
List available MCP tools for the authenticated user.
"""
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"}
)
# Validate username matches authenticated user (unless admin/global token)
if not is_global_token and not is_admin:
from aisbf.database import get_database
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."}
)
# User-specific MCP tools
tools = mcp_server.get_available_tools(MCPAuthLevel.USER, user_id)
return {"tools": tools}
@app.post("/mcp/u/{username}/tools/call")
async def mcp_user_call_tool(request: Request, username: str):
"""
Call an MCP tool for the authenticated user via HTTP POST.
"""
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"}
)
# Validate username matches authenticated user (unless admin/global token)
if not is_global_token and not is_admin:
from aisbf.database import get_database
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."}
)
# Parse request body
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 HTTPException as e:
return JSONResponse(
status_code=e.status_code,
content={"error": e.detail}
)
except Exception as e:
logger.error(f"Error calling MCP tool: {e}")
return JSONResponse(
status_code=500,
content={"error": str(e)}
)
@app.get("/mcp/tools")
async def mcp_list_tools(request: Request):
"""
......@@ -8149,26 +8251,37 @@ async def user_list_models_by_username(request: Request, username: str):
return {"object": "list", "data": all_models}
@app.get("/api/user/models")
async def user_list_models(request: Request):
@app.get("/api/u/{username}/models")
async def user_list_models(request: Request, username: str):
"""
List all available models for the authenticated user.
This includes the user's own providers, rotations, and autoselects.
Admin users can also access all users' configurations.
Authentication is done via Bearer token in the Authorization header.
Returns models from:
- User-configured providers
- User-configured rotations
- User-configured rotations
- User-configured autoselects
Example:
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/user/models
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/username/models
"""
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)
# Validate username matches authenticated user (unless admin/global token)
if not is_global_token and not is_admin and user_id:
from aisbf.database import get_database
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."}
)
# Global tokens and admin users can access all configurations
if is_global_token or is_admin:
......@@ -8343,20 +8456,31 @@ async def user_list_models(request: Request):
return {"object": "list", "data": all_models}
@app.get("/api/user/providers")
async def user_list_providers(request: Request):
@app.get("/api/u/{username}/providers")
async def user_list_providers(request: Request, username: str):
"""
List all provider configurations for the authenticated user.
Admin users and global tokens can access all configurations.
Authentication is done via Bearer token in the Authorization header.
Example:
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/user/providers
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/username/providers
"""
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)
# Validate username matches authenticated user (unless admin/global token)
if not is_global_token and not is_admin and user_id:
from aisbf.database import get_database
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."}
)
# Global tokens and admin users can access all configurations
if is_global_token or is_admin:
......@@ -8452,20 +8576,31 @@ async def user_list_providers(request: Request):
return {"providers": providers_info}
@app.get("/api/user/rotations")
async def user_list_rotations(request: Request):
@app.get("/api/u/{username}/rotations")
async def user_list_rotations(request: Request, username: str):
"""
List all rotation configurations for the authenticated user.
Admin users and global tokens can access all configurations.
Authentication is done via Bearer token in the Authorization header.
Example:
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/user/rotations
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/username/rotations
"""
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)
# Validate username matches authenticated user (unless admin/global token)
if not is_global_token and not is_admin and user_id:
from aisbf.database import get_database
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."}
)
# Global tokens and admin users can access all configurations
if is_global_token or is_admin:
......@@ -8519,20 +8654,31 @@ async def user_list_rotations(request: Request):
return {"rotations": rotations_info}
@app.get("/api/user/autoselects")
async def user_list_autoselects(request: Request):
@app.get("/api/u/{username}/autoselects")
async def user_list_autoselects(request: Request, username: str):
"""
List all autoselect configurations for the authenticated user.
Admin users and global tokens can access all configurations.
Authentication is done via Bearer token in the Authorization header.
Example:
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/user/autoselects
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/username/autoselects
"""
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)
# Validate username matches authenticated user (unless admin/global token)
if not is_global_token and not is_admin and user_id:
from aisbf.database import get_database
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."}
)
# Global tokens and admin users can access all configurations
if is_global_token or is_admin:
......@@ -9146,8 +9292,8 @@ async def user_list_config_models_by_username(request: Request, username: str, c
# These endpoints allow authenticated users to use their own configurations
# Admin users and global tokens can also access global configurations
@app.post("/api/user/chat/completions")
async def user_chat_completions(request: Request, body: ChatCompletionRequest):
@app.post("/api/u/{username}/chat/completions")
async def user_chat_completions(request: Request, username: str, body: ChatCompletionRequest):
"""
Handle chat completions using the authenticated user's configurations.
......@@ -9172,7 +9318,18 @@ async def user_chat_completions(request: Request, 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)
# Validate username matches authenticated user (unless admin/global token)
if not is_global_token and not is_admin and user_id:
from aisbf.database import get_database
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."
)
# Parse provider from model field
provider_id, actual_model = parse_provider_from_model(body.model)
......@@ -9289,20 +9446,34 @@ async def user_chat_completions(request: Request, body: ChatCompletionRequest):
# User-specific model listing endpoint
@app.get("/api/user/{config_type}/models")
async def user_list_config_models(request: Request, config_type: str):
@app.get("/api/u/{username}/{config_type}/models")
async def user_list_config_models(request: Request, username: str, config_type: str):
"""
List models for a specific user configuration type.
Args:
username: Username of the user
config_type: One of 'providers', 'rotations', or 'autoselects'
Authentication is done via Bearer token in the Authorization header.
Example:
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/user/rotations/models
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/username/rotations/models
"""
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)
# Validate username matches authenticated user (unless admin/global token)
if not is_global_token and not is_admin and user_id:
from aisbf.database import get_database
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(
......
......@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "aisbf"
version = "0.99.21"
version = "0.99.25"
description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations"
readme = "README.md"
license = "GPL-3.0-or-later"
......
......@@ -49,7 +49,7 @@ class InstallCommand(_install):
setup(
name="aisbf",
version="0.99.21",
version="0.99.25",
author="AISBF Contributors",
author_email="stefy@nexlab.net",
description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations",
......
......@@ -461,7 +461,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<body>
<div class="header">
<div class="container">
<h1>AISBF Dashboard</h1>
<h1>AISBF Dashboard <span style="font-size: 0.8em; font-weight: normal; opacity: 0.7;">v{{ __version__ }}</span></h1>
{% if request.session.logged_in %}
<div class="header-actions">
<a href="{{ url_for(request, '/dashboard/docs') }}" class="btn btn-secondary">Docs</a>
......@@ -501,6 +501,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<a href="{{ url_for(request, '/dashboard/autoselect') }}" {% if '/autoselect' in request.path %}class="active"{% endif %}>Autoselect</a>
<a href="{{ url_for(request, '/dashboard/prompts') }}" {% if '/prompts' in request.path %}class="active"{% endif %}>Prompts</a>
<a href="{{ url_for(request, '/dashboard/analytics') }}" {% if '/analytics' in request.path %}class="active"{% endif %}>Analytics</a>
<a href="{{ url_for(request, '/dashboard/user/tokens') }}" {% if '/user/tokens' in request.path %}class="active"{% endif %}>API Tokens</a>
{% if request.session.role == 'admin' %}
<a href="{{ url_for(request, '/dashboard/users') }}" {% if '/users' in request.path %}class="active"{% endif %}>Users</a>
<a href="{{ url_for(request, '/dashboard/settings') }}" {% if '/settings' in request.path %}class="active"{% endif %}>Settings</a>
......@@ -519,6 +520,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% if request.session.user_id %}
{% if request.session.user_id %}
<a href="{{ url_for(request, '/dashboard/profile') }}">Edit Profile</a>
<a href="{{ url_for(request, '/dashboard/user/tokens') }}">API Tokens</a>
<a href="{{ url_for(request, '/dashboard/subscription') }}">Subscription</a>
<a href="{{ url_for(request, '/dashboard/billing') }}">Billing</a>
<a href="{{ url_for(request, '/dashboard/change-password') }}">Change Password</a>
......
......@@ -89,6 +89,13 @@
Tier is active and available for users
</label>
</div>
<div style="margin-bottom: 15px;">
<label style="cursor: pointer; font-weight: 500; color: #e0e0e0;">
<input type="checkbox" name="is_visible" value="1" {{ 'checked' if not tier or tier.is_visible else '' }} style="margin-right: 10px;">
Tier is visible to users (if unchecked, only admin can assign this tier)
</label>
</div>
</div>
<div style="display: flex; gap: 10px;">
......
......@@ -744,9 +744,10 @@ function savePaymentGateways() {
function showToast(message, type) {
// Create and show toast notification
const toastContainer = document.createElement('div');
toastContainer.className = `position-fixed bottom-0 end-0 p-3`;
toastContainer.className = `position-fixed top-0 end-0 p-3`;
toastContainer.style.zIndex = '9999';
toastContainer.innerHTML = `
<div class="toast show" role="alert">
<div class="toast show" role="alert" style="min-width: 300px;">
<div class="toast-header bg-${type} text-white">
<strong class="me-auto">Notification</strong>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast"></button>
......@@ -757,8 +758,13 @@ function showToast(message, type) {
</div>
`;
document.body.appendChild(toastContainer);
setTimeout(() => toastContainer.remove(), 3000);
// Auto-dismiss after 5 seconds
setTimeout(() => {
if (toastContainer.parentNode) {
toastContainer.remove();
}
}, 5000);
}
</script>
{% endblock %}
\ No newline at end of file
......@@ -67,15 +67,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% endif %}
</form>
{% if config.oauth2 and (config.oauth2.google.enabled or config.oauth2.github.enabled) %}
{% if config.oauth2 and ((config.oauth2.google and config.oauth2.google.enabled) or (config.oauth2.github and config.oauth2.github.enabled)) %}
<div style="margin: 25px 0; text-align: center; position: relative;">
<hr style="border: none; border-top: 1px solid #ddd; margin: 0;">
<span style="background: white; padding: 0 15px; position: relative; top: -13px; color: #666;">or continue with</span>
<hr style="border: none; border-top: 1px solid #0f3460; margin: 0;">
<span style="background: #16213e; padding: 0 15px; position: relative; top: -13px; color: #a0a0a0;">or continue with</span>
</div>
<div style="display: flex; flex-direction: column; gap: 12px;">
{% if config.oauth2.google.enabled %}
<a href="/auth/oauth2/google" style="display: flex; align-items: center; justify-content: center; gap: 10px; padding: 12px; border: 1px solid #dadce0; border-radius: 4px; background: white; color: #3c4043; font-weight: 500; text-decoration: none; transition: background 0.2s;">
{% if config.oauth2.google and config.oauth2.google.enabled %}
<a href="/auth/oauth2/google" style="display: flex; align-items: center; justify-content: center; gap: 10px; padding: 12px; border: 1px solid #0f3460; border-radius: 4px; background: #1a1a2e; color: #e0e0e0; font-weight: 500; text-decoration: none; transition: background 0.2s;">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
......@@ -86,8 +86,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</a>
{% endif %}
{% if config.oauth2.github.enabled %}
<a href="/auth/oauth2/github" style="display: flex; align-items: center; justify-content: center; gap: 10px; padding: 12px; border: 1px solid #d1d5da; border-radius: 4px; background: #24292e; color: #ffffff; font-weight: 500; text-decoration: none;">
{% if config.oauth2.github and config.oauth2.github.enabled %}
<a href="/auth/oauth2/github" style="display: flex; align-items: center; justify-content: center; gap: 10px; padding: 12px; border: 1px solid #0f3460; border-radius: 4px; background: #24292e; color: #ffffff; font-weight: 500; text-decoration: none; transition: background 0.2s;">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
......
......@@ -262,9 +262,10 @@ function downgradeToFree() {
function showToast(message, type) {
const toastContainer = document.createElement('div');
toastContainer.className = `position-fixed bottom-0 end-0 p-3`;
toastContainer.className = `position-fixed top-0 end-0 p-3`;
toastContainer.style.zIndex = '9999';
toastContainer.innerHTML = `
<div class="toast show" role="alert">
<div class="toast show" role="alert" style="min-width: 300px;">
<div class="toast-header bg-${type} text-white">
<strong class="me-auto">Notification</strong>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast"></button>
......@@ -275,8 +276,13 @@ function showToast(message, type) {
</div>
`;
document.body.appendChild(toastContainer);
setTimeout(() => toastContainer.remove(), 3000);
// Auto-dismiss after 5 seconds
setTimeout(() => {
if (toastContainer.parentNode) {
toastContainer.remove();
}
}, 5000);
}
</script>
{% endblock %}
\ No newline at end of file
......@@ -60,6 +60,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
OpenAI-compatible provider. Uses standard API key authentication.
</p>
</div>
<div id="new-provider-claude-warning" style="background: #ffdd57; border: 2px solid #ff6b35; border-radius: 8px; padding: 20px; margin-bottom: 15px; display: none;">
<h4 style="margin: 0 0 10px 0; color: #d63031;">⚠️ Important Notice for Claude Provider</h4>
<p style="margin: 0; color: #2d3436;">
Claude.ai policies state that unofficial clients are not allowed. By using AISBF as a client, you acknowledge that:
<br><br>• While we do our best to mimic the claude-code CLI, using an unofficial client can lead to account suspension or cancellation
<br>• You use this software at your own risk and responsibility
<br>• We are not affiliated with Anthropic and cannot guarantee compatibility or continued functionality
</p>
</div>
<div style="display: flex; gap: 10px;">
<button type="button" class="btn" onclick="confirmAddProvider()">Create Provider</button>
......@@ -317,13 +327,13 @@ function renderProviderDetails(key) {
<small style="color: #a0a0a0; display: block; margin-bottom: 15px;">
Claude uses OAuth2 authentication. Click "Authenticate" to start the OAuth2 flow in your browser.
</small>
<div class="form-group">
<label>Credentials File Path</label>
<input type="text" value="${claudeConfig.credentials_file || '~/.claude_credentials.json'}" onchange="updateClaudeConfig('${key}', 'credentials_file', this.value)" placeholder="~/.claude_credentials.json">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Path where OAuth2 credentials will be stored</small>
</div>
<div style="margin-top: 15px;">
<button type="button" class="btn" onclick="authenticateClaude('${key}')" style="background: #4a9eff;">
🔐 Authenticate with Claude
......@@ -332,11 +342,11 @@ function renderProviderDetails(key) {
Check Status
</button>
</div>
<div id="claude-auth-status-${key}" style="margin-top: 10px; padding: 10px; border-radius: 3px; display: none;">
<!-- Auth status will be displayed here -->
</div>
<h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Or Upload Credentials File</h5>
<div class="form-group">
<label>Upload Credentials File</label>
......@@ -345,6 +355,16 @@ function renderProviderDetails(key) {
</div>
<div id="claude-upload-status-${key}" style="margin-top: 10px;"></div>
</div>
<div style="background: #ffdd57; border: 2px solid #ff6b35; border-radius: 8px; padding: 20px; margin-bottom: 15px;">
<h4 style="margin: 0 0 10px 0; color: #d63031;">⚠️ Important Notice</h4>
<p style="margin: 0; color: #2d3436;">
Claude.ai policies state that unofficial clients are not allowed. By using AISBF as a client, you acknowledge that:
<br><br>• While we do our best to mimic the claude-code CLI, using an unofficial client can lead to account suspension or cancellation
<br>• You use this software at your own risk and responsibility
<br>• We are not affiliated with Anthropic and cannot guarantee compatibility or continued functionality
</p>
</div>
`;
} else if (isQwenProvider) {
// Qwen authentication fields - supports both API key and OAuth2
......@@ -737,7 +757,7 @@ function cancelAddProvider() {
function updateNewProviderDefaults() {
const providerType = document.getElementById('new-provider-type').value;
const descriptionEl = document.getElementById('new-provider-type-description');
const descriptions = {
'openai': 'OpenAI-compatible provider. Uses standard API key authentication. Endpoint: https://api.openai.com/v1',
'google': 'Google AI provider (Gemini). Uses API key authentication. Endpoint: https://generativelanguage.googleapis.com/v1beta',
......@@ -749,8 +769,16 @@ function updateNewProviderDefaults() {
'qwen': 'Qwen provider. Uses OAuth2 Device Authorization Grant or API key. Endpoint: https://dashscope.aliyuncs.com/compatible-mode/v1',
'codex': 'Codex provider. Uses OAuth2 Device Authorization Grant (same protocol as OpenAI). Endpoint: https://api.openai.com/v1'
};
descriptionEl.textContent = descriptions[providerType] || 'Standard provider configuration.';
// Show/hide Claude warning
const claudeWarningEl = document.getElementById('new-provider-claude-warning');
if (providerType === 'claude') {
claudeWarningEl.style.display = 'block';
} else {
claudeWarningEl.style.display = 'none';
}
}
function confirmAddProvider() {
......
......@@ -470,6 +470,49 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
<div id="tor-fields" style="display: {% if config.tor and config.tor.enabled %}block{% else %}none{% endif %};">
<!-- torrc Configuration Instructions -->
<div style="background: #16213e; padding: 15px; border-radius: 6px; margin-bottom: 20px; border-left: 4px solid #2196f3;">
<h4 style="margin: 0 0 10px 0; color: #2196f3;">⚙️ Required torrc Configuration</h4>
<p style="margin: 0 0 10px 0; color: #ccc;">Before enabling Tor, you must configure your torrc file (usually <code style="background: #0f3460; padding: 2px 6px; border-radius: 3px;">/etc/tor/torrc</code>):</p>
<div style="background: #0f3460; padding: 12px; border-radius: 4px; margin-bottom: 10px;">
<strong style="color: #4caf50;">Step 1: Generate Password Hash</strong>
<pre style="margin: 8px 0 0 0; padding: 8px; background: #000; border-radius: 3px; overflow-x: auto;"><code>tor --hash-password "your_secure_password"</code></pre>
</div>
<div style="background: #0f3460; padding: 12px; border-radius: 4px; margin-bottom: 10px;">
<strong style="color: #4caf50;">Step 2: Add to torrc</strong>
<div style="margin-top: 8px;">
<button type="button" onclick="toggleTorrcExample('ephemeral')" style="background: #2196f3; color: white; border: none; padding: 6px 12px; border-radius: 3px; cursor: pointer; margin-right: 8px;">Ephemeral Service</button>
<button type="button" onclick="toggleTorrcExample('persistent')" style="background: #2196f3; color: white; border: none; padding: 6px 12px; border-radius: 3px; cursor: pointer;">Persistent Service</button>
</div>
<div id="torrc-ephemeral" style="display: none; margin-top: 10px;">
<p style="margin: 0 0 8px 0; color: #ccc; font-size: 0.9em;">For temporary hidden service (new address each restart):</p>
<pre style="margin: 0; padding: 8px; background: #000; border-radius: 3px; overflow-x: auto;"><code>ControlPort 9051
HashedControlPassword 16:YOUR_HASHED_PASSWORD_HERE</code></pre>
<p style="margin: 8px 0 0 0; color: #ff9800; font-size: 0.85em;">⚠️ Leave "Hidden Service Directory" field blank below for ephemeral service</p>
</div>
<div id="torrc-persistent" style="display: none; margin-top: 10px;">
<p style="margin: 0 0 8px 0; color: #ccc; font-size: 0.9em;">For permanent hidden service (same address across restarts):</p>
<pre style="margin: 0; padding: 8px; background: #000; border-radius: 3px; overflow-x: auto;"><code>ControlPort 9051
HashedControlPassword 16:YOUR_HASHED_PASSWORD_HERE
HiddenServiceDir <span id="torrc-hs-dir">/home/yourusername/.aisbf/tor_hidden_service</span>
HiddenServicePort <span id="torrc-hs-port">80</span> 127.0.0.1:<span id="torrc-local-port">{{ config.server.port if config.server else 17765 }}</span></code></pre>
<p style="margin: 8px 0 0 0; color: #ff9800; font-size: 0.85em;">⚠️ Replace <code style="background: #16213e; padding: 2px 4px; border-radius: 2px;">/home/yourusername</code> with your actual home directory path (torrc does NOT expand ~)</p>
</div>
</div>
<div style="background: #0f3460; padding: 12px; border-radius: 4px;">
<strong style="color: #4caf50;">Step 3: Restart Tor</strong>
<pre style="margin: 8px 0 0 0; padding: 8px; background: #000; border-radius: 3px; overflow-x: auto;"><code>sudo systemctl restart tor # Linux
brew services restart tor # macOS</code></pre>
</div>
</div>
<div class="form-group">
<label for="tor_control_host">TOR Control Host</label>
<input type="text" id="tor_control_host" name="tor_control_host" value="{{ config.tor.control_host if config.tor else '127.0.0.1' }}">
......@@ -479,28 +522,32 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="form-group">
<label for="tor_control_port">TOR Control Port</label>
<input type="number" id="tor_control_port" name="tor_control_port" value="{{ config.tor.control_port if config.tor else 9051 }}">
<small style="color: #666; display: block; margin-top: 5px;">TOR control port (default: 9051)</small>
<small style="color: #666; display: block; margin-top: 5px;">TOR control port (default: 9051) - must match ControlPort in torrc</small>
</div>
<div class="form-group">
<label for="tor_control_password">TOR Control Password</label>
<input type="password" id="tor_control_password" name="tor_control_password" value="{{ config.tor.control_password if config.tor and config.tor.control_password else '' }}" placeholder="Leave blank if no password">
<small style="color: #666; display: block; margin-top: 5px;">Password for TOR control port authentication (optional)</small>
<input type="password" id="tor_control_password" name="tor_control_password" value="{{ config.tor.control_password if config.tor and config.tor.control_password else '' }}" placeholder="Enter the password you used to generate the hash">
<small style="color: #666; display: block; margin-top: 5px;">The password you used with <code>tor --hash-password</code> (NOT the hash itself)</small>
</div>
<div class="form-group">
<label for="tor_hidden_service_dir">Hidden Service Directory</label>
<div style="display: flex; gap: 10px; align-items: flex-start;">
<input type="text" id="tor_hidden_service_dir" name="tor_hidden_service_dir" value="{{ config.tor.hidden_service_dir if config.tor and config.tor.hidden_service_dir else '' }}" placeholder="Leave blank for ephemeral service" style="flex: 1;">
<button type="button" onclick="createPersistentService()" class="btn btn-secondary" style="white-space: nowrap;">Create Persistent</button>
<input type="text" id="tor_hidden_service_dir" name="tor_hidden_service_dir" value="{{ config.tor.hidden_service_dir if config.tor and config.tor.hidden_service_dir else '' }}" placeholder="Leave blank for ephemeral service" style="flex: 1;" onchange="updateTorrcExample()">
<button type="button" onclick="createPersistentService()" class="btn btn-secondary" style="white-space: nowrap;">Use Default</button>
</div>
<small style="color: #666; display: block; margin-top: 5px;">Directory for persistent hidden service. Leave blank for ephemeral (temporary) service. Click "Create Persistent" to use default directory.</small>
<small style="color: #666; display: block; margin-top: 5px;">
<strong>Ephemeral:</strong> Leave blank for temporary service (new address each restart)<br>
<strong>Persistent:</strong> Enter full absolute path (e.g., /home/username/.aisbf/tor_hidden_service) - must match HiddenServiceDir in torrc<br>
<span style="color: #ff9800;">⚠️ Note: torrc does NOT expand ~ - use full paths like /home/username instead</span>
</small>
</div>
<div class="form-group">
<label for="tor_hidden_service_port">Hidden Service Port</label>
<input type="number" id="tor_hidden_service_port" name="tor_hidden_service_port" value="{{ config.tor.hidden_service_port if config.tor else 80 }}">
<small style="color: #666; display: block; margin-top: 5px;">Port exposed on the hidden service (default: 80)</small>
<input type="number" id="tor_hidden_service_port" name="tor_hidden_service_port" value="{{ config.tor.hidden_service_port if config.tor else 80 }}" onchange="updateTorrcExample()">
<small style="color: #666; display: block; margin-top: 5px;">Port exposed on the onion address (default: 80) - must match HiddenServicePort in torrc for persistent services</small>
</div>
<div class="form-group">
......@@ -926,7 +973,8 @@ function toggleResponseCacheFields() {
function createPersistentService() {
const dirInput = document.getElementById('tor_hidden_service_dir');
const defaultDir = '~/.aisbf/tor_hidden_service';
// Use absolute path instead of ~ since torrc doesn't expand it
const defaultDir = '/home/{{ os.environ.get("USER", "yourusername") }}/.aisbf/tor_hidden_service';
if (dirInput.value && dirInput.value.trim() !== '') {
if (!confirm('This will replace the current directory path. Continue?')) {
......@@ -936,6 +984,7 @@ function createPersistentService() {
dirInput.value = defaultDir;
dirInput.focus();
updateTorrcExample();
// Visual feedback
dirInput.style.backgroundColor = '#1a4d2e';
......@@ -944,6 +993,42 @@ function createPersistentService() {
}, 500);
}
function toggleTorrcExample(type) {
const ephemeralDiv = document.getElementById('torrc-ephemeral');
const persistentDiv = document.getElementById('torrc-persistent');
if (type === 'ephemeral') {
ephemeralDiv.style.display = 'block';
persistentDiv.style.display = 'none';
} else {
ephemeralDiv.style.display = 'none';
persistentDiv.style.display = 'block';
updateTorrcExample();
}
}
function updateTorrcExample() {
const hsDir = document.getElementById('tor_hidden_service_dir').value || '/home/yourusername/.aisbf/tor_hidden_service';
const hsPort = document.getElementById('tor_hidden_service_port').value || '80';
const localPort = document.getElementById('port').value || '{{ config.server.port if config.server else 17765 }}';
// Expand ~ to actual home directory path for torrc display
let expandedHsDir = hsDir;
if (hsDir.startsWith('~')) {
// Get home directory from server (we'll add an endpoint for this)
// For now, show a placeholder that makes it clear it needs expansion
expandedHsDir = hsDir.replace('~', '/home/yourusername');
}
const hsDirSpan = document.getElementById('torrc-hs-dir');
const hsPortSpan = document.getElementById('torrc-hs-port');
const localPortSpan = document.getElementById('torrc-local-port');
if (hsDirSpan) hsDirSpan.textContent = expandedHsDir;
if (hsPortSpan) hsPortSpan.textContent = hsPort;
if (localPortSpan) localPortSpan.textContent = localPort;
}
async function checkTorStatus() {
try {
const response = await fetch('{{ url_for(request, "/dashboard/tor/status") }}');
......
......@@ -79,15 +79,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
</form>
{% if config.oauth2 and (config.oauth2.google.enabled or config.oauth2.github.enabled) %}
{% if config.oauth2 and ((config.oauth2.google and config.oauth2.google.enabled) or (config.oauth2.github and config.oauth2.github.enabled)) %}
<div style="margin: 25px 0; text-align: center; position: relative;">
<hr style="border: none; border-top: 1px solid #ddd; margin: 0;">
<span style="background: white; padding: 0 15px; position: relative; top: -13px; color: #666;">or sign up with</span>
<hr style="border: none; border-top: 1px solid #0f3460; margin: 0;">
<span style="background: #16213e; padding: 0 15px; position: relative; top: -13px; color: #a0a0a0;">or sign up with</span>
</div>
<div style="display: flex; flex-direction: column; gap: 12px;">
{% if config.oauth2.google.enabled %}
<a href="/auth/oauth2/google" style="display: flex; align-items: center; justify-content: center; gap: 10px; padding: 12px; border: 1px solid #dadce0; border-radius: 4px; background: white; color: #3c4043; font-weight: 500; text-decoration: none; transition: background 0.2s;">
{% if config.oauth2.google and config.oauth2.google.enabled %}
<a href="/auth/oauth2/google" style="display: flex; align-items: center; justify-content: center; gap: 10px; padding: 12px; border: 1px solid #0f3460; border-radius: 4px; background: #1a1a2e; color: #e0e0e0; font-weight: 500; text-decoration: none; transition: background 0.2s;">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
......@@ -98,8 +98,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</a>
{% endif %}
{% if config.oauth2.github.enabled %}
<a href="/auth/oauth2/github" style="display: flex; align-items: center; justify-content: center; gap: 10px; padding: 12px; border: 1px solid #d1d5da; border-radius: 4px; background: #24292e; color: #ffffff; font-weight: 500; text-decoration: none;">
{% if config.oauth2.github and config.oauth2.github.enabled %}
<a href="/auth/oauth2/github" style="display: flex; align-items: center; justify-content: center; gap: 10px; padding: 12px; border: 1px solid #0f3460; border-radius: 4px; background: #24292e; color: #ffffff; font-weight: 500; text-decoration: none; transition: background 0.2s;">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
......
......@@ -66,12 +66,12 @@
<h3>MCP Tools</h3>
<div class="endpoint">
<code class="method GET">GET</code>
<code class="url">{{ get_base_url(request) }}/mcp/tools</code>
<code class="url">{{ get_base_url(request) }}/mcp/u/{{ session.username }}/tools</code>
<p>List available MCP tools for your configurations</p>
</div>
<div class="endpoint">
<code class="method POST">POST</code>
<code class="url">{{ get_base_url(request) }}/mcp/tools/call</code>
<code class="url">{{ get_base_url(request) }}/mcp/u/{{ session.username }}/tools/call</code>
<p>Call MCP tools to manage your configurations</p>
</div>
</div>
......
......@@ -33,37 +33,37 @@
<tbody>
<tr>
<td><code class="method GET">GET</code></td>
<td><code>/api/user/models</code></td>
<td><code>/api/u/{{ session.username }}/models</code></td>
<td>List your models</td>
</tr>
<tr>
<td><code class="method GET">GET</code></td>
<td><code>/api/user/providers</code></td>
<td><code>/api/u/{{ session.username }}/providers</code></td>
<td>List your providers</td>
</tr>
<tr>
<td><code class="method GET">GET</code></td>
<td><code>/api/user/rotations</code></td>
<td><code>/api/u/{{ session.username }}/rotations</code></td>
<td>List your rotations</td>
</tr>
<tr>
<td><code class="method GET">GET</code></td>
<td><code>/api/user/autoselects</code></td>
<td><code>/api/u/{{ session.username }}/autoselects</code></td>
<td>List your autoselects</td>
</tr>
<tr>
<td><code class="method POST">POST</code></td>
<td><code>/api/user/chat/completions</code></td>
<td><code>/api/u/{{ session.username }}/chat/completions</code></td>
<td>Chat using your configs</td>
</tr>
<tr>
<td><code class="method GET">GET</code></td>
<td><code>/mcp/tools</code></td>
<td><code>/mcp/u/{{ session.username }}/tools</code></td>
<td>List MCP tools</td>
</tr>
<tr>
<td><code class="method POST">POST</code></td>
<td><code>/mcp/tools/call</code></td>
<td><code>/mcp/u/{{ session.username }}/tools/call</code></td>
<td>Call MCP tools</td>
</tr>
</tbody>
......@@ -71,16 +71,16 @@
<h3>Example curl commands:</h3>
<pre style="background: #1a1a2e; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code># List your models
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/user/models
curl -H "Authorization: Bearer YOUR_TOKEN" {{ request.base_url }}api/u/{{ session.username }}/models
# List your providers
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/user/providers
# List your providers
curl -H "Authorization: Bearer YOUR_TOKEN" {{ request.base_url }}api/u/{{ session.username }}/providers
# Send a chat request
curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"model": "user-rotation/myrotation", "messages": [{"role": "user", "content": "Hello"}]}' \
http://localhost:17765/api/user/chat/completions</code></pre>
{{ request.base_url }}api/u/{{ session.username }}/chat/completions</code></pre>
</div>
<div class="card">
......
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