Commit 0c895e05 authored by Your Name's avatar Your Name

feat: Add intelligent 429 rate limit handling and improve configuration

- Implement intelligent 429 rate limit parsing in providers
  - Parse Retry-After and X-RateLimit-Reset headers
  - Extract wait time from response body fields
  - Parse error messages for time patterns
  - Automatically disable providers for exact duration specified

- Update aisbf.sh to read port from config file dynamically
  - Add get_port() function to parse aisbf.json
  - Remove hardcoded port 17765
  - Show port number when starting server

- Implement SHA256 password hashing for dashboard
  - Store password as hash in aisbf.json
  - Hash passwords during login validation
  - Hash passwords when updating settings

- Add templates to package distribution
  - Update setup.py to include dashboard templates
  - Update MANIFEST.in (already included templates)
  - Add aisbf.json to package

- Update documentation
  - Add OpenAI-compatible v1 endpoints to README
  - Add dashboard endpoints documentation
  - Create comprehensive API_EXAMPLES.md with examples in cURL, Python, and JavaScript

- Add GPL license headers to all template files
  - HTML templates in templates/ directory
  - JavaScript code in templates/base.html

All changes maintain backward compatibility while adding new features.
parent 3f6647d2
# AISBF API Examples
This document provides practical examples for using the AISBF API endpoints.
## Table of Contents
- [OpenAI-Compatible v1 Endpoints](#openai-compatible-v1-endpoints)
- [Chat Completions](#chat-completions)
- [Audio Endpoints](#audio-endpoints)
- [Image Generation](#image-generation)
- [Embeddings](#embeddings)
- [Model Listing](#model-listing)
- [Rotations](#rotations)
- [Autoselect](#autoselect)
## OpenAI-Compatible v1 Endpoints
The v1 endpoints follow the standard OpenAI API format with `provider/model` notation.
### Chat Completions
#### Using cURL
```bash
curl -X POST http://localhost:17765/api/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "openai/gpt-4",
"messages": [
{"role": "user", "content": "Hello, how are you?"}
]
}'
```
#### Using Python
```python
import requests
response = requests.post(
"http://localhost:17765/api/v1/chat/completions",
json={
"model": "openai/gpt-4",
"messages": [
{"role": "user", "content": "Hello, how are you?"}
]
}
)
print(response.json())
```
#### Using Python with OpenAI SDK
```python
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:17765/api/v1",
api_key="dummy" # Not required if auth is disabled
)
response = client.chat.completions.create(
model="openai/gpt-4",
messages=[
{"role": "user", "content": "Hello, how are you?"}
]
)
print(response.choices[0].message.content)
```
#### Streaming Response
```python
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:17765/api/v1",
api_key="dummy"
)
stream = client.chat.completions.create(
model="gemini/gemini-2.0-flash",
messages=[
{"role": "user", "content": "Write a short poem"}
],
stream=True
)
for chunk in stream:
if chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="")
```
#### Using Different Providers
```bash
# Google Gemini
curl -X POST http://localhost:17765/api/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gemini/gemini-2.0-flash",
"messages": [{"role": "user", "content": "Hello"}]
}'
# Anthropic Claude
curl -X POST http://localhost:17765/api/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "anthropic/claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hello"}]
}'
# Ollama (local)
curl -X POST http://localhost:17765/api/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "ollama/llama2",
"messages": [{"role": "user", "content": "Hello"}]
}'
```
## Audio Endpoints
### Audio Transcription
```bash
curl -X POST http://localhost:17765/api/v1/audio/transcriptions \
-F "file=@audio.mp3" \
-F "model=openai/whisper-1"
```
```python
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:17765/api/v1",
api_key="dummy"
)
with open("audio.mp3", "rb") as audio_file:
transcript = client.audio.transcriptions.create(
model="openai/whisper-1",
file=audio_file
)
print(transcript.text)
```
### Text-to-Speech
```bash
curl -X POST http://localhost:17765/api/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"model": "openai/tts-1",
"input": "Hello, this is a test.",
"voice": "alloy"
}' \
--output speech.mp3
```
```python
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:17765/api/v1",
api_key="dummy"
)
response = client.audio.speech.create(
model="openai/tts-1",
voice="alloy",
input="Hello, this is a test."
)
response.stream_to_file("speech.mp3")
```
## Image Generation
```bash
curl -X POST http://localhost:17765/api/v1/images/generations \
-H "Content-Type: application/json" \
-d '{
"model": "openai/dall-e-3",
"prompt": "A beautiful sunset over mountains",
"n": 1,
"size": "1024x1024"
}'
```
```python
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:17765/api/v1",
api_key="dummy"
)
response = client.images.generate(
model="openai/dall-e-3",
prompt="A beautiful sunset over mountains",
n=1,
size="1024x1024"
)
print(response.data[0].url)
```
## Embeddings
```bash
curl -X POST http://localhost:17765/api/v1/embeddings \
-H "Content-Type: application/json" \
-d '{
"model": "openai/text-embedding-ada-002",
"input": "The quick brown fox jumps over the lazy dog"
}'
```
```python
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:17765/api/v1",
api_key="dummy"
)
response = client.embeddings.create(
model="openai/text-embedding-ada-002",
input="The quick brown fox jumps over the lazy dog"
)
print(response.data[0].embedding)
```
## Model Listing
### List All Models
```bash
curl http://localhost:17765/api/v1/models
```
```python
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:17765/api/v1",
api_key="dummy"
)
models = client.models.list()
for model in models.data:
print(f"{model.id} - {model.owned_by}")
```
## Rotations
Rotations provide weighted load balancing across multiple providers.
### Using Rotation with v1 Endpoint
```bash
curl -X POST http://localhost:17765/api/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "coding",
"messages": [
{"role": "user", "content": "Write a Python function to sort a list"}
]
}'
```
### Using Legacy Rotation Endpoint
```bash
curl -X POST http://localhost:17765/api/rotations/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "coding",
"messages": [
{"role": "user", "content": "Write a Python function to sort a list"}
]
}'
```
### List Available Rotations
```bash
curl http://localhost:17765/api/rotations
```
### List Rotation Models
```bash
curl http://localhost:17765/api/rotations/models
```
## Autoselect
Autoselect uses AI to automatically select the best model based on your request.
### Using Autoselect with v1 Endpoint
```bash
curl -X POST http://localhost:17765/api/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "autoselect",
"messages": [
{"role": "user", "content": "Debug this Python code: def add(a,b): return a-b"}
]
}'
```
### Using Legacy Autoselect Endpoint
```bash
curl -X POST http://localhost:17765/api/autoselect/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "autoselect",
"messages": [
{"role": "user", "content": "Debug this Python code: def add(a,b): return a-b"}
]
}'
```
### List Available Autoselect Configurations
```bash
curl http://localhost:17765/api/autoselect
```
## Legacy Provider Endpoints
You can also use provider-specific endpoints:
```bash
# Direct provider access
curl -X POST http://localhost:17765/api/openai/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}]
}'
# List provider models
curl http://localhost:17765/api/openai/models
```
## Authentication
If authentication is enabled in your configuration:
```bash
curl -X POST http://localhost:17765/api/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{
"model": "openai/gpt-4",
"messages": [{"role": "user", "content": "Hello"}]
}'
```
```python
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:17765/api/v1",
api_key="YOUR_TOKEN_HERE"
)
response = client.chat.completions.create(
model="openai/gpt-4",
messages=[{"role": "user", "content": "Hello"}]
)
```
## JavaScript/Node.js Examples
### Using fetch API
```javascript
const response = await fetch('http://localhost:17765/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'openai/gpt-4',
messages: [
{ role: 'user', content: 'Hello, how are you?' }
]
})
});
const data = await response.json();
console.log(data.choices[0].message.content);
```
### Using OpenAI SDK
```javascript
import OpenAI from 'openai';
const client = new OpenAI({
baseURL: 'http://localhost:17765/api/v1',
apiKey: 'dummy'
});
const response = await client.chat.completions.create({
model: 'openai/gpt-4',
messages: [
{ role: 'user', content: 'Hello, how are you?' }
]
});
console.log(response.choices[0].message.content);
```
### Streaming in JavaScript
```javascript
import OpenAI from 'openai';
const client = new OpenAI({
baseURL: 'http://localhost:17765/api/v1',
apiKey: 'dummy'
});
const stream = await client.chat.completions.create({
model: 'gemini/gemini-2.0-flash',
messages: [
{ role: 'user', content: 'Write a short poem' }
],
stream: true
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
process.stdout.write(content);
}
```
## Error Handling
```python
from openai import OpenAI, OpenAIError
client = OpenAI(
base_url="http://localhost:17765/api/v1",
api_key="dummy"
)
try:
response = client.chat.completions.create(
model="openai/gpt-4",
messages=[
{"role": "user", "content": "Hello"}
]
)
print(response.choices[0].message.content)
except OpenAIError as e:
print(f"Error: {e}")
```
## Advanced Features
### Context Condensation
When using models with large context windows, AISBF automatically condenses context when approaching limits:
```python
# Large context will be automatically condensed
response = client.chat.completions.create(
model="gemini/gemini-2.0-flash",
messages=[
{"role": "user", "content": "Very long prompt..."},
# ... many messages
]
)
```
### Rate Limiting
AISBF automatically handles rate limits and rotates to available providers:
```python
# If rate limit is hit, AISBF will automatically use another provider
for i in range(100):
response = client.chat.completions.create(
model="coding", # Rotation with multiple providers
messages=[{"role": "user", "content": f"Request {i}"}]
)
```
## Dashboard Access
Access the web dashboard at:
```
http://localhost:17765/dashboard
```
Default credentials:
- Username: `admin`
- Password: `admin` (SHA256 hashed in config)
## License
Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
...@@ -7,4 +7,7 @@ include aisbf.sh ...@@ -7,4 +7,7 @@ include aisbf.sh
include cli.py include cli.py
recursive-include config *.json recursive-include config *.json
recursive-include config *.md recursive-include config *.md
recursive-include aisbf *.py recursive-include aisbf *.py
\ No newline at end of file recursive-include templates *.html
recursive-include templates *.css
recursive-include templates *.js
\ No newline at end of file
...@@ -109,9 +109,26 @@ See `config/providers.json` and `config/rotations.json` for configuration exampl ...@@ -109,9 +109,26 @@ See `config/providers.json` and `config/rotations.json` for configuration exampl
### General Endpoints ### General Endpoints
- `GET /` - Server status and provider list (includes providers, rotations, and autoselect) - `GET /` - Server status and provider list (includes providers, rotations, and autoselect)
### Provider Endpoints ### OpenAI-Compatible v1 Endpoints (Recommended)
These endpoints follow the standard OpenAI API format with `provider/model` notation:
- `POST /api/v1/chat/completions` - Chat completions (model format: `provider/model-name`)
- Example: `{"model": "openai/gpt-4", "messages": [...]}`
- Supports providers, rotations, and autoselect
- `GET /api/v1/models` - List all available models from all providers
- `POST /api/v1/audio/transcriptions` - Audio transcription (model format: `provider/model-name`)
- `POST /api/v1/audio/speech` - Text-to-speech (model format: `provider/model-name`)
- `POST /api/v1/images/generations` - Image generation (model format: `provider/model-name`)
- `POST /api/v1/embeddings` - Text embeddings (model format: `provider/model-name`)
- `GET /api/proxy/{content_id}` - Proxy generated content (images, audio, etc.)
### Provider Endpoints (Legacy)
- `POST /api/{provider_id}/chat/completions` - Chat completions for a specific provider - `POST /api/{provider_id}/chat/completions` - Chat completions for a specific provider
- `GET /api/{provider_id}/models` - List available models for a specific provider - `GET /api/{provider_id}/models` - List available models for a specific provider
- `POST /api/{provider_id}/audio/transcriptions` - Audio transcription
- `POST /api/{provider_id}/audio/speech` - Text-to-speech
- `POST /api/{provider_id}/images/generations` - Image generation
- `POST /api/{provider_id}/embeddings` - Text embeddings
### Rotation Endpoints ### Rotation Endpoints
- `GET /api/rotations` - List all available rotation configurations - `GET /api/rotations` - List all available rotation configurations
...@@ -131,6 +148,17 @@ See `config/providers.json` and `config/rotations.json` for configuration exampl ...@@ -131,6 +148,17 @@ See `config/providers.json` and `config/rotations.json` for configuration exampl
- Supports both streaming and non-streaming responses - Supports both streaming and non-streaming responses
- `GET /api/autoselect/models` - List all models across all autoselect configurations - `GET /api/autoselect/models` - List all models across all autoselect configurations
### Dashboard Endpoints
- `GET /dashboard` - Web-based configuration dashboard
- `GET /dashboard/login` - Dashboard login page
- `POST /dashboard/login` - Handle dashboard authentication
- `GET /dashboard/logout` - Logout from dashboard
- `GET /dashboard/providers` - Edit providers configuration
- `GET /dashboard/rotations` - Edit rotations configuration
- `GET /dashboard/autoselect` - Edit autoselect configuration
- `GET /dashboard/settings` - Edit server settings
- `POST /dashboard/restart` - Restart the server
## Error Handling ## Error Handling
- Rate limiting for failed requests - Rate limiting for failed requests
- Automatic retry with provider rotation - Automatic retry with provider rotation
......
...@@ -41,6 +41,37 @@ fi ...@@ -41,6 +41,37 @@ fi
# Create log directory if it doesn't exist # Create log directory if it doesn't exist
mkdir -p "$LOG_DIR" mkdir -p "$LOG_DIR"
# Function to get port from config file
get_port() {
local CONFIG_FILE="$SHARE_DIR/config/aisbf.json"
local DEFAULT_PORT=17765
# Check if config file exists
if [ ! -f "$CONFIG_FILE" ]; then
echo "$DEFAULT_PORT"
return
fi
# Try to read port from config using Python
local PORT=$(python3 -c "
import json
import sys
try:
with open('$CONFIG_FILE', 'r') as f:
config = json.load(f)
print(config.get('port', $DEFAULT_PORT))
except:
print($DEFAULT_PORT)
" 2>/dev/null)
# Validate port is a number
if [[ "$PORT" =~ ^[0-9]+$ ]]; then
echo "$PORT"
else
echo "$DEFAULT_PORT"
fi
}
# Function to create venv if it doesn't exist # Function to create venv if it doesn't exist
ensure_venv() { ensure_venv() {
if [ ! -d "$VENV_DIR" ]; then if [ ! -d "$VENV_DIR" ]; then
...@@ -79,15 +110,20 @@ start_server() { ...@@ -79,15 +110,20 @@ start_server() {
# Update venv packages silently # Update venv packages silently
update_venv update_venv
# Get port from config
PORT=$(get_port)
# Activate the virtual environment # Activate the virtual environment
source $VENV_DIR/bin/activate source $VENV_DIR/bin/activate
# Change to share directory where main.py is located # Change to share directory where main.py is located
cd $SHARE_DIR cd $SHARE_DIR
echo "Starting AISBF on port $PORT..."
# Start the proxy server with logging # Start the proxy server with logging
# Redirect stderr to suppress BrokenPipeError during shutdown # Redirect stderr to suppress BrokenPipeError during shutdown
uvicorn main:app --host 127.0.0.1 --port 17765 2>&1 | while IFS= read -r line; do uvicorn main:app --host 127.0.0.1 --port $PORT 2>&1 | while IFS= read -r line; do
# Filter out BrokenPipeError logging errors # Filter out BrokenPipeError logging errors
if [[ "$line" != *"--- Logging error ---"* ]] && [[ "$line" != *"BrokenPipeError"* ]] && [[ "$line" != *"Call stack:"* ]] && [[ "$line" != *"File "*"/python"* ]] && [[ "$line" != *"Message:"* ]] && [[ "$line" != *"Arguments:"* ]]; then if [[ "$line" != *"--- Logging error ---"* ]] && [[ "$line" != *"BrokenPipeError"* ]] && [[ "$line" != *"Call stack:"* ]] && [[ "$line" != *"File "*"/python"* ]] && [[ "$line" != *"Message:"* ]] && [[ "$line" != *"Arguments:"* ]]; then
echo "$line" | tee -a "$LOG_DIR/aisbf_stdout.log" echo "$line" | tee -a "$LOG_DIR/aisbf_stdout.log"
...@@ -115,9 +151,14 @@ start_daemon() { ...@@ -115,9 +151,14 @@ start_daemon() {
# Update venv packages silently # Update venv packages silently
update_venv update_venv
# Get port from config
PORT=$(get_port)
echo "Starting AISBF on port $PORT in background..."
# Start in background with nohup and logging # Start in background with nohup and logging
# Filter out BrokenPipeError logging errors # Filter out BrokenPipeError logging errors
nohup bash -c "source $VENV_DIR/bin/activate && cd $SHARE_DIR && uvicorn main:app --host 127.0.0.1 --port 17765 2>&1 | grep -v '--- Logging error ---' | grep -v 'BrokenPipeError' | grep -v 'Call stack:' | grep -v 'File .*python' | grep -v 'Message:' | grep -v 'Arguments:'" >> "$LOG_DIR/aisbf_stdout.log" 2>&1 & nohup bash -c "source $VENV_DIR/bin/activate && cd $SHARE_DIR && uvicorn main:app --host 127.0.0.1 --port $PORT 2>&1 | grep -v '--- Logging error ---' | grep -v 'BrokenPipeError' | grep -v 'Call stack:' | grep -v 'File .*python' | grep -v 'Message:' | grep -v 'Arguments:'" >> "$LOG_DIR/aisbf_stdout.log" 2>&1 &
PID=$! PID=$!
echo $PID > "$PIDFILE" echo $PID > "$PIDFILE"
echo "AISBF started in background (PID: $PID)" echo "AISBF started in background (PID: $PID)"
......
...@@ -648,9 +648,438 @@ class RequestHandler: ...@@ -648,9 +648,438 @@ class RequestHandler:
await handler.apply_rate_limit() await handler.apply_rate_limit()
models = await handler.get_models() models = await handler.get_models()
return [model.dict() for model in models]
# Enhance model information with context window and capabilities
enhanced_models = []
for model in models:
model_dict = model.dict()
model_name = model_dict.get('id', '')
# Try to find model config in provider config
model_config = None
if provider_config.models:
for m in provider_config.models:
if m.name == model_name:
model_config = m
break
# Add context window information
if model_config and hasattr(model_config, 'context_size'):
model_dict['context_window'] = model_config.context_size
elif 'context_window' not in model_dict:
# Try to infer from model name or set a default
model_dict['context_window'] = self._infer_context_window(model_name, provider_config.type)
# Add capabilities information
if model_config and hasattr(model_config, 'capabilities'):
model_dict['capabilities'] = model_config.capabilities
elif 'capabilities' not in model_dict:
# Auto-detect capabilities based on model name and provider type
model_dict['capabilities'] = self._detect_capabilities(model_name, provider_config.type)
enhanced_models.append(model_dict)
return enhanced_models
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
def _infer_context_window(self, model_name: str, provider_type: str) -> int:
"""Infer context window size from model name or provider type"""
model_lower = model_name.lower()
# Known model patterns
if 'gpt-4' in model_lower:
if 'turbo' in model_lower or '1106' in model_lower or '0125' in model_lower:
return 128000
return 8192
elif 'gpt-3.5' in model_lower:
if 'turbo' in model_lower and ('1106' in model_lower or '0125' in model_lower):
return 16385
return 4096
elif 'claude-3' in model_lower:
return 200000
elif 'claude-2' in model_lower:
return 100000
elif 'gemini' in model_lower:
if '1.5' in model_lower:
return 2000000 if 'pro' in model_lower else 1000000
elif '2.0' in model_lower:
return 1000000
return 32000
elif 'llama' in model_lower:
if '3' in model_lower:
return 128000
return 4096
elif 'mistral' in model_lower:
if 'large' in model_lower:
return 32000
return 8192
# Default based on provider type
if provider_type == 'google':
return 32000
elif provider_type == 'anthropic':
return 100000
elif provider_type == 'openai':
return 8192
# Generic default
return 4096
def _detect_capabilities(self, model_name: str, provider_type: str) -> List[str]:
"""Auto-detect model capabilities based on model name and provider type"""
model_lower = model_name.lower()
capabilities = []
# Text-to-text (default for most models)
if not any(keyword in model_lower for keyword in ['embedding', 'embed', 'whisper', 'tts', 'dall-e', 'stable-diffusion']):
capabilities.append('t2t')
# Text-to-image generation
if any(keyword in model_lower for keyword in ['dall-e', 'dalle', 'stable-diffusion', 'sd-', 'sdxl', 'midjourney', 'imagen', 'flux']):
capabilities.append('t2i')
# Image-to-image (editing, style transfer)
if any(keyword in model_lower for keyword in ['stable-diffusion', 'sd-', 'sdxl', 'controlnet', 'img2img']):
capabilities.append('i2i')
# Vision/Image understanding (image-to-text)
if any(keyword in model_lower for keyword in ['vision', 'gpt-4-turbo', 'gpt-4o', 'claude-3', 'gemini-1.5', 'gemini-2.0', 'gemini-pro-vision', 'llava', 'blip']):
capabilities.append('vision')
capabilities.append('i2t')
# Audio transcription (audio-to-text)
if any(keyword in model_lower for keyword in ['whisper', 'transcribe', 'speech-to-text', 'stt']):
capabilities.append('transcription')
capabilities.append('a2t')
# Text-to-speech
if any(keyword in model_lower for keyword in ['tts', 'text-to-speech', 'elevenlabs', 'bark', 'tortoise']):
capabilities.append('tts')
capabilities.append('t2a')
# Text-to-video generation
if any(keyword in model_lower for keyword in ['sora', 'runway', 'pika', 'text-to-video', 't2v']):
capabilities.append('t2v')
# Image-to-video generation
if any(keyword in model_lower for keyword in ['runway', 'pika', 'img2video', 'i2v']):
capabilities.append('i2v')
# Video-to-video (editing)
if any(keyword in model_lower for keyword in ['runway', 'video-edit', 'v2v']):
capabilities.append('v2v')
# Video understanding (video-to-text)
if any(keyword in model_lower for keyword in ['video-llama', 'video-chat', 'v2t']):
capabilities.append('v2t')
# Audio-to-audio (music generation, audio processing)
if any(keyword in model_lower for keyword in ['musicgen', 'audiogen', 'riffusion', 'a2a']):
capabilities.append('a2a')
# Text embeddings
if any(keyword in model_lower for keyword in ['embedding', 'embed', 'ada-002', 'bge', 'e5', 'instructor']):
capabilities.append('embeddings')
# Function calling / tool use
if any(keyword in model_lower for keyword in ['gpt-4', 'gpt-3.5-turbo', 'claude-3', 'gemini', 'function', 'tool']):
capabilities.append('function_calling')
# Code generation
if any(keyword in model_lower for keyword in ['codex', 'code-', 'starcoder', 'codellama', 'deepseek-coder', 'phind']):
capabilities.append('code_generation')
capabilities.append('code_completion')
# Translation
if any(keyword in model_lower for keyword in ['translate', 'translation', 'm2m', 'nllb']):
capabilities.append('translation')
# Summarization
if any(keyword in model_lower for keyword in ['summarize', 'summary', 'bart', 'pegasus']):
capabilities.append('summarization')
# Classification
if any(keyword in model_lower for keyword in ['classifier', 'classification', 'bert-', 'roberta-']):
capabilities.append('classification')
# Sentiment analysis
if any(keyword in model_lower for keyword in ['sentiment', 'emotion']):
capabilities.append('sentiment_analysis')
# Named Entity Recognition
if any(keyword in model_lower for keyword in ['ner', 'entity', 'spacy']):
capabilities.append('ner')
# Question answering
if any(keyword in model_lower for keyword in ['qa', 'question', 'squad']):
capabilities.append('question_answering')
# Reasoning (chain-of-thought)
if any(keyword in model_lower for keyword in ['reasoning', 'cot', 'o1', 'o3']):
capabilities.append('reasoning')
# Search / RAG
if any(keyword in model_lower for keyword in ['search', 'retrieval', 'rag']):
capabilities.append('search')
# Content moderation
if any(keyword in model_lower for keyword in ['moderation', 'safety', 'content-filter']):
capabilities.append('moderation')
# Fine-tuning support
if any(keyword in model_lower for keyword in ['fine-tune', 'finetune', 'ft-']):
capabilities.append('fine_tuning')
# Multimodal (multiple input/output types)
if any(keyword in model_lower for keyword in ['gpt-4o', 'gemini', 'claude-3', 'multimodal', 'mm-']):
capabilities.append('multimodal')
# OCR (Optical Character Recognition)
if any(keyword in model_lower for keyword in ['ocr', 'tesseract', 'paddleocr', 'easyocr']):
capabilities.append('ocr')
# Image captioning
if any(keyword in model_lower for keyword in ['caption', 'blip', 'git-']):
capabilities.append('image_captioning')
# Object detection
if any(keyword in model_lower for keyword in ['yolo', 'detection', 'rcnn', 'detr']):
capabilities.append('object_detection')
# Segmentation
if any(keyword in model_lower for keyword in ['segment', 'sam', 'mask']):
capabilities.append('segmentation')
# 3D generation
if any(keyword in model_lower for keyword in ['3d', 'nerf', 'gaussian', 'mesh']):
capabilities.append('3d_generation')
# Animation
if any(keyword in model_lower for keyword in ['animate', 'motion', 'pose']):
capabilities.append('animation')
return capabilities
async def handle_audio_transcription(self, request: Request, provider_id: str, form_data) -> Dict:
"""Handle audio transcription requests"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"=== Audio Transcription Handler START ===")
provider_config = self.config.get_provider(provider_id)
if provider_config.api_key_required:
api_key = request.headers.get('Authorization', '').replace('Bearer ', '')
if not api_key:
raise HTTPException(status_code=401, detail="API key required")
else:
api_key = None
handler = get_provider_handler(provider_id, api_key)
if handler.is_rate_limited():
raise HTTPException(status_code=503, detail="Provider temporarily unavailable")
try:
await handler.apply_rate_limit()
result = await handler.handle_audio_transcription(form_data)
handler.record_success()
return result
except Exception as e:
handler.record_failure()
raise HTTPException(status_code=500, detail=str(e))
async def handle_text_to_speech(self, request: Request, provider_id: str, request_data: Dict) -> StreamingResponse:
"""Handle text-to-speech requests"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"=== Text-to-Speech Handler START ===")
provider_config = self.config.get_provider(provider_id)
if provider_config.api_key_required:
api_key = request_data.get('api_key') or request.headers.get('Authorization', '').replace('Bearer ', '')
if not api_key:
raise HTTPException(status_code=401, detail="API key required")
else:
api_key = None
handler = get_provider_handler(provider_id, api_key)
if handler.is_rate_limited():
raise HTTPException(status_code=503, detail="Provider temporarily unavailable")
try:
await handler.apply_rate_limit()
result = await handler.handle_text_to_speech(request_data)
handler.record_success()
return result
except Exception as e:
handler.record_failure()
raise HTTPException(status_code=500, detail=str(e))
async def handle_image_generation(self, request: Request, provider_id: str, request_data: Dict) -> Dict:
"""Handle image generation requests with URL rewriting"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"=== Image Generation Handler START ===")
provider_config = self.config.get_provider(provider_id)
if provider_config.api_key_required:
api_key = request_data.get('api_key') or request.headers.get('Authorization', '').replace('Bearer ', '')
if not api_key:
raise HTTPException(status_code=401, detail="API key required")
else:
api_key = None
handler = get_provider_handler(provider_id, api_key)
if handler.is_rate_limited():
raise HTTPException(status_code=503, detail="Provider temporarily unavailable")
try:
await handler.apply_rate_limit()
result = await handler.handle_image_generation(request_data)
# Rewrite URLs in the response to point to our proxy
result = self._rewrite_content_urls(result, request)
handler.record_success()
return result
except Exception as e:
handler.record_failure()
raise HTTPException(status_code=500, detail=str(e))
async def handle_embeddings(self, request: Request, provider_id: str, request_data: Dict) -> Dict:
"""Handle embeddings requests"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"=== Embeddings Handler START ===")
provider_config = self.config.get_provider(provider_id)
if provider_config.api_key_required:
api_key = request_data.get('api_key') or request.headers.get('Authorization', '').replace('Bearer ', '')
if not api_key:
raise HTTPException(status_code=401, detail="API key required")
else:
api_key = None
handler = get_provider_handler(provider_id, api_key)
if handler.is_rate_limited():
raise HTTPException(status_code=503, detail="Provider temporarily unavailable")
try:
await handler.apply_rate_limit()
result = await handler.handle_embeddings(request_data)
handler.record_success()
return result
except Exception as e:
handler.record_failure()
raise HTTPException(status_code=500, detail=str(e))
def _rewrite_content_urls(self, response: Dict, request: Request) -> Dict:
"""Rewrite content URLs to point to our proxy endpoint"""
import logging
import hashlib
import json
logger = logging.getLogger(__name__)
# Get the base URL from the request
scheme = request.url.scheme
host = request.headers.get('host', request.url.netloc)
base_url = f"{scheme}://{host}"
# Store URL mappings in a simple in-memory cache (in production, use Redis or similar)
if not hasattr(self, '_url_cache'):
self._url_cache = {}
def rewrite_url(original_url: str) -> str:
"""Rewrite a single URL"""
# Check if URL is already public and accessible
if self._is_public_url(original_url):
logger.info(f"URL is public, passing through: {original_url}")
return original_url
# Generate a unique ID for this URL
url_hash = hashlib.md5(original_url.encode()).hexdigest()[:16]
# Store the mapping
self._url_cache[url_hash] = original_url
# Return the proxy URL
proxy_url = f"{base_url}/api/proxy/{url_hash}"
logger.info(f"Rewrote URL: {original_url} -> {proxy_url}")
return proxy_url
# Recursively rewrite URLs in the response
def rewrite_recursive(obj):
if isinstance(obj, dict):
for key, value in obj.items():
if key in ['url', 'image_url', 'audio_url', 'video_url'] and isinstance(value, str):
obj[key] = rewrite_url(value)
else:
obj[key] = rewrite_recursive(value)
elif isinstance(obj, list):
return [rewrite_recursive(item) for item in obj]
return obj
return rewrite_recursive(response)
def _is_public_url(self, url: str) -> bool:
"""Check if a URL is publicly accessible (doesn't need proxying)"""
# URLs from major CDNs and public services don't need proxying
public_domains = [
'cloudflare.com',
'amazonaws.com',
'googleusercontent.com',
'azure.com',
'cdn.',
'storage.googleapis.com'
]
return any(domain in url.lower() for domain in public_domains)
async def handle_content_proxy(self, content_id: str) -> StreamingResponse:
"""Proxy content from the original URL"""
import logging
import httpx
logger = logging.getLogger(__name__)
# Get the original URL from cache
if not hasattr(self, '_url_cache'):
self._url_cache = {}
original_url = self._url_cache.get(content_id)
if not original_url:
raise HTTPException(status_code=404, detail="Content not found")
logger.info(f"Proxying content: {content_id} -> {original_url}")
try:
# Fetch the content from the original URL
async with httpx.AsyncClient() as client:
response = await client.get(original_url, follow_redirects=True)
response.raise_for_status()
# Determine content type
content_type = response.headers.get('content-type', 'application/octet-stream')
# Return the content as a streaming response
return StreamingResponse(
iter([response.content]),
media_type=content_type,
headers={
'Content-Disposition': response.headers.get('content-disposition', ''),
'Cache-Control': 'public, max-age=3600'
}
)
except Exception as e:
logger.error(f"Error proxying content: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error fetching content: {str(e)}")
class RotationHandler: class RotationHandler:
def __init__(self): def __init__(self):
...@@ -1940,16 +2369,127 @@ class RotationHandler: ...@@ -1940,16 +2369,127 @@ class RotationHandler:
all_models = [] all_models = []
for provider in rotation_config.providers: for provider in rotation_config.providers:
provider_id = provider['provider_id']
provider_config = self.config.get_provider(provider_id)
for model in provider['models']: for model in provider['models']:
all_models.append({ model_name = model['name']
"id": f"{provider['provider_id']}/{model['name']}", model_dict = {
"name": model['name'], "id": f"{provider_id}/{model_name}",
"provider_id": provider['provider_id'], "name": model_name,
"provider_id": provider_id,
"weight": model['weight'], "weight": model['weight'],
"rate_limit": model.get('rate_limit') "rate_limit": model.get('rate_limit')
}) }
# Add context window information
if model.get('context_size'):
model_dict['context_window'] = model['context_size']
elif provider_config:
# Try to find in provider config
for pm in provider_config.models or []:
if pm.name == model_name and hasattr(pm, 'context_size'):
model_dict['context_window'] = pm.context_size
break
if 'context_window' not in model_dict:
model_dict['context_window'] = self._infer_context_window(model_name, provider_config.type)
# Add capabilities information
if model.get('capabilities'):
model_dict['capabilities'] = model['capabilities']
elif provider_config:
# Try to find in provider config
for pm in provider_config.models or []:
if pm.name == model_name and hasattr(pm, 'capabilities'):
model_dict['capabilities'] = pm.capabilities
break
if 'capabilities' not in model_dict:
model_dict['capabilities'] = self._detect_capabilities(model_name, provider_config.type)
all_models.append(model_dict)
return all_models return all_models
def _infer_context_window(self, model_name: str, provider_type: str) -> int:
"""Infer context window size from model name or provider type"""
model_lower = model_name.lower()
# Known model patterns
if 'gpt-4' in model_lower:
if 'turbo' in model_lower or '1106' in model_lower or '0125' in model_lower:
return 128000
return 8192
elif 'gpt-3.5' in model_lower:
if 'turbo' in model_lower and ('1106' in model_lower or '0125' in model_lower):
return 16385
return 4096
elif 'claude-3' in model_lower:
return 200000
elif 'claude-2' in model_lower:
return 100000
elif 'gemini' in model_lower:
if '1.5' in model_lower:
return 2000000 if 'pro' in model_lower else 1000000
elif '2.0' in model_lower:
return 1000000
return 32000
elif 'llama' in model_lower:
if '3' in model_lower:
return 128000
return 4096
elif 'mistral' in model_lower:
if 'large' in model_lower:
return 32000
return 8192
# Default based on provider type
if provider_type == 'google':
return 32000
elif provider_type == 'anthropic':
return 100000
elif provider_type == 'openai':
return 8192
# Generic default
return 4096
def _detect_capabilities(self, model_name: str, provider_type: str) -> List[str]:
"""Auto-detect model capabilities based on model name and provider type"""
model_lower = model_name.lower()
capabilities = []
# Text-to-text is the default capability for all models
capabilities.append('t2t')
# Image generation models
if any(keyword in model_lower for keyword in ['dall-e', 'dalle', 'stable-diffusion', 'sd-', 'midjourney', 'imagen']):
capabilities.append('t2i')
# Vision models (can process images)
if any(keyword in model_lower for keyword in ['vision', 'gpt-4-turbo', 'gpt-4o', 'claude-3', 'gemini-1.5', 'gemini-2.0']):
capabilities.append('vision')
# Audio transcription models
if any(keyword in model_lower for keyword in ['whisper', 'transcribe']):
capabilities.append('transcription')
# Text-to-speech models
if any(keyword in model_lower for keyword in ['tts', 'text-to-speech', 'elevenlabs']):
capabilities.append('tts')
# Video generation models
if any(keyword in model_lower for keyword in ['sora', 'runway', 'pika', 'video']):
capabilities.append('i2v')
# Embedding models
if any(keyword in model_lower for keyword in ['embedding', 'embed', 'ada-002']):
capabilities.append('embeddings')
# Function calling / tool use
if any(keyword in model_lower for keyword in ['gpt-4', 'gpt-3.5-turbo', 'claude-3', 'gemini']):
capabilities.append('function_calling')
return capabilities
class AutoselectHandler: class AutoselectHandler:
def __init__(self): def __init__(self):
......
...@@ -49,6 +49,180 @@ class BaseProviderHandler: ...@@ -49,6 +49,180 @@ class BaseProviderHandler:
self.model_last_request_time = {} # {model_name: timestamp} self.model_last_request_time = {} # {model_name: timestamp}
# Token usage tracking for rate limits # Token usage tracking for rate limits
self.token_usage = {} # {model_name: {"TPM": [], "TPH": [], "TPD": []}} self.token_usage = {} # {model_name: {"TPM": [], "TPH": [], "TPD": []}}
def parse_429_response(self, response_data: Union[Dict, str], headers: Dict = None) -> Optional[int]:
"""
Parse 429 rate limit response to extract wait time in seconds.
Checks multiple sources:
1. Retry-After header (seconds or HTTP date)
2. X-RateLimit-Reset header (Unix timestamp)
3. Response body fields (retry_after, reset_time, etc.)
Returns:
Wait time in seconds, or None if cannot be determined
"""
import logging
import re
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
logger.info("=== PARSING 429 RATE LIMIT RESPONSE ===")
wait_seconds = None
# Check Retry-After header
if headers:
retry_after = headers.get('Retry-After') or headers.get('retry-after')
if retry_after:
logger.info(f"Found Retry-After header: {retry_after}")
try:
# Try parsing as integer (seconds)
wait_seconds = int(retry_after)
logger.info(f"Parsed Retry-After as seconds: {wait_seconds}")
except ValueError:
# Try parsing as HTTP date
try:
retry_date = parsedate_to_datetime(retry_after)
now = datetime.now(timezone.utc)
wait_seconds = int((retry_date - now).total_seconds())
logger.info(f"Parsed Retry-After as date, wait seconds: {wait_seconds}")
except Exception as e:
logger.warning(f"Failed to parse Retry-After header: {e}")
# Check X-RateLimit-Reset header (Unix timestamp)
if not wait_seconds:
reset_time = headers.get('X-RateLimit-Reset') or headers.get('x-ratelimit-reset')
if reset_time:
logger.info(f"Found X-RateLimit-Reset header: {reset_time}")
try:
reset_timestamp = int(reset_time)
now_timestamp = int(time.time())
wait_seconds = reset_timestamp - now_timestamp
logger.info(f"Calculated wait from reset timestamp: {wait_seconds} seconds")
except Exception as e:
logger.warning(f"Failed to parse X-RateLimit-Reset header: {e}")
# Check response body
if not wait_seconds and isinstance(response_data, dict):
logger.info(f"Checking response body for rate limit info: {response_data}")
# Common field names for retry/reset time
retry_fields = [
'retry_after', 'retryAfter', 'retry_after_seconds',
'wait_seconds', 'waitSeconds', 'retry_in'
]
reset_fields = [
'reset_time', 'resetTime', 'reset_at', 'resetAt',
'reset_timestamp', 'resetTimestamp'
]
# Check retry fields (direct seconds)
for field in retry_fields:
if field in response_data:
try:
wait_seconds = int(response_data[field])
logger.info(f"Found {field} in response body: {wait_seconds} seconds")
break
except (ValueError, TypeError) as e:
logger.warning(f"Failed to parse {field}: {e}")
# Check reset fields (timestamp)
if not wait_seconds:
for field in reset_fields:
if field in response_data:
try:
reset_timestamp = int(response_data[field])
now_timestamp = int(time.time())
wait_seconds = reset_timestamp - now_timestamp
logger.info(f"Found {field} in response body, calculated wait: {wait_seconds} seconds")
break
except (ValueError, TypeError) as e:
logger.warning(f"Failed to parse {field}: {e}")
# Check for error message with time information
if not wait_seconds:
error_msg = response_data.get('error', {})
if isinstance(error_msg, dict):
message = error_msg.get('message', '')
elif isinstance(error_msg, str):
message = error_msg
else:
message = response_data.get('message', '')
if message:
logger.info(f"Checking error message for time info: {message}")
# Look for patterns like "try again in X seconds/minutes/hours"
patterns = [
r'try again in (\d+)\s*(second|minute|hour|day)s?',
r'retry after (\d+)\s*(second|minute|hour|day)s?',
r'wait (\d+)\s*(second|minute|hour|day)s?',
r'available in (\d+)\s*(second|minute|hour|day)s?',
]
for pattern in patterns:
match = re.search(pattern, message, re.IGNORECASE)
if match:
value = int(match.group(1))
unit = match.group(2).lower()
# Convert to seconds
multipliers = {
'second': 1,
'minute': 60,
'hour': 3600,
'day': 86400
}
wait_seconds = value * multipliers.get(unit, 1)
logger.info(f"Extracted wait time from message: {value} {unit}(s) = {wait_seconds} seconds")
break
# Ensure wait_seconds is positive and reasonable
if wait_seconds:
if wait_seconds < 0:
logger.warning(f"Calculated negative wait time: {wait_seconds}, setting to 60 seconds")
wait_seconds = 60
elif wait_seconds > 86400: # More than 1 day
logger.warning(f"Calculated very long wait time: {wait_seconds}, capping at 1 day")
wait_seconds = 86400
logger.info(f"Final parsed wait time: {wait_seconds} seconds")
else:
logger.warning("Could not determine wait time from 429 response, using default 60 seconds")
wait_seconds = 60
logger.info("=== END PARSING 429 RATE LIMIT RESPONSE ===")
return wait_seconds
def handle_429_error(self, response_data: Union[Dict, str] = None, headers: Dict = None):
"""
Handle 429 rate limit error by parsing the response and disabling provider
for the appropriate duration.
Args:
response_data: Response body (dict or string)
headers: Response headers
"""
import logging
logger = logging.getLogger(__name__)
logger.error("=== 429 RATE LIMIT ERROR DETECTED ===")
logger.error(f"Provider: {self.provider_id}")
# Parse the response to get wait time
wait_seconds = self.parse_429_response(response_data, headers)
# Disable provider for the calculated duration
self.error_tracking['disabled_until'] = time.time() + wait_seconds
logger.error(f"!!! PROVIDER DISABLED DUE TO RATE LIMIT !!!")
logger.error(f"Provider: {self.provider_id}")
logger.error(f"Reason: 429 Too Many Requests")
logger.error(f"Disabled for: {wait_seconds} seconds ({wait_seconds / 60:.1f} minutes)")
logger.error(f"Disabled until: {self.error_tracking['disabled_until']}")
logger.error(f"Provider will be automatically re-enabled after cooldown")
logger.error("=== END 429 RATE LIMIT ERROR ===")
def is_rate_limited(self) -> bool: def is_rate_limited(self) -> bool:
if self.error_tracking['disabled_until'] and self.error_tracking['disabled_until'] > time.time(): if self.error_tracking['disabled_until'] and self.error_tracking['disabled_until'] > time.time():
...@@ -1329,6 +1503,19 @@ class KiroProviderHandler(BaseProviderHandler): ...@@ -1329,6 +1503,19 @@ class KiroProviderHandler(BaseProviderHandler):
headers=headers headers=headers
) )
# Check for 429 rate limit error before raising
if response.status_code == 429:
try:
response_data = response.json()
except Exception:
response_data = response.text
# Handle 429 error with intelligent parsing
self.handle_429_error(response_data, dict(response.headers))
# Re-raise the error after handling
response.raise_for_status()
response.raise_for_status() response.raise_for_status()
response_data = response.json() response_data = response.json()
...@@ -1534,6 +1721,20 @@ class OllamaProviderHandler(BaseProviderHandler): ...@@ -1534,6 +1721,20 @@ class OllamaProviderHandler(BaseProviderHandler):
logger.info(f"Response content type: {response.headers.get('content-type')}") logger.info(f"Response content type: {response.headers.get('content-type')}")
logger.info(f"Response content length: {len(response.content)} bytes") logger.info(f"Response content length: {len(response.content)} bytes")
logger.info(f"Raw response content (first 500 chars): {response.text[:500]}") logger.info(f"Raw response content (first 500 chars): {response.text[:500]}")
# Check for 429 rate limit error before raising
if response.status_code == 429:
try:
response_data = response.json()
except Exception:
response_data = response.text
# Handle 429 error with intelligent parsing
self.handle_429_error(response_data, dict(response.headers))
# Re-raise the error after handling
response.raise_for_status()
response.raise_for_status() response.raise_for_status()
# Ollama may return multiple JSON objects, parse them all # Ollama may return multiple JSON objects, parse them all
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
"dashboard": { "dashboard": {
"enabled": true, "enabled": true,
"username": "admin", "username": "admin",
"password": "admin" "password": "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918"
}, },
"internal_model": { "internal_model": {
"model_id": "huihui-ai/Qwen2.5-0.5B-Instruct-abliterated-v3" "model_id": "huihui-ai/Qwen2.5-0.5B-Instruct-abliterated-v3"
......
...@@ -24,7 +24,8 @@ ...@@ -24,7 +24,8 @@
"rate_limit_TPD": 1000000, "rate_limit_TPD": 1000000,
"context_size": 1000000, "context_size": 1000000,
"condense_context": 80, "condense_context": 80,
"condense_method": ["hierarchical", "semantic"] "condense_method": ["hierarchical", "semantic"],
"capabilities": ["t2t", "vision", "function_calling"]
}, },
{ {
"name": "gemini-1.5-pro", "name": "gemini-1.5-pro",
...@@ -35,7 +36,8 @@ ...@@ -35,7 +36,8 @@
"rate_limit_TPD": 1000000, "rate_limit_TPD": 1000000,
"context_size": 2000000, "context_size": 2000000,
"condense_context": 85, "condense_context": 85,
"condense_method": "conversational" "condense_method": "conversational",
"capabilities": ["t2t", "vision", "function_calling"]
} }
] ]
}, },
......
...@@ -38,6 +38,7 @@ import sys ...@@ -38,6 +38,7 @@ import sys
import os import os
import argparse import argparse
import secrets import secrets
import hashlib
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from datetime import datetime, timedelta from datetime import datetime, timedelta
from collections import defaultdict from collections import defaultdict
...@@ -47,6 +48,9 @@ import json ...@@ -47,6 +48,9 @@ import json
# Global variable to store custom config directory # Global variable to store custom config directory
_custom_config_dir = None _custom_config_dir = None
# Global variable to store original command line arguments for restart
_original_argv = None
def set_config_dir(config_dir: str): def set_config_dir(config_dir: str):
"""Set custom config directory before importing config""" """Set custom config directory before importing config"""
global _custom_config_dir global _custom_config_dir
...@@ -449,7 +453,14 @@ async def dashboard_login_page(request: Request): ...@@ -449,7 +453,14 @@ async def dashboard_login_page(request: Request):
async def dashboard_login(request: Request, username: str = Form(...), password: str = Form(...)): async def dashboard_login(request: Request, username: str = Form(...), password: str = Form(...)):
"""Handle dashboard login""" """Handle dashboard login"""
dashboard_config = server_config.get('dashboard_config', {}) dashboard_config = server_config.get('dashboard_config', {})
if username == dashboard_config.get('username', 'admin') and password == dashboard_config.get('password', 'admin'): stored_username = dashboard_config.get('username', 'admin')
stored_password_hash = dashboard_config.get('password', '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918')
# Hash the submitted password
password_hash = hashlib.sha256(password.encode()).hexdigest()
# Compare username and hashed password
if username == stored_username and password_hash == stored_password_hash:
request.session['logged_in'] = True request.session['logged_in'] = True
request.session['username'] = username request.session['username'] = username
return RedirectResponse(url="/dashboard", status_code=303) return RedirectResponse(url="/dashboard", status_code=303)
...@@ -735,8 +746,9 @@ async def dashboard_settings_save( ...@@ -735,8 +746,9 @@ async def dashboard_settings_save(
aisbf_config['auth']['enabled'] = auth_enabled aisbf_config['auth']['enabled'] = auth_enabled
aisbf_config['auth']['tokens'] = [t.strip() for t in auth_tokens.split('\n') if t.strip()] aisbf_config['auth']['tokens'] = [t.strip() for t in auth_tokens.split('\n') if t.strip()]
aisbf_config['dashboard']['username'] = dashboard_username aisbf_config['dashboard']['username'] = dashboard_username
if dashboard_password: # Only update if provided if dashboard_password: # Only update if provided - hash the password
aisbf_config['dashboard']['password'] = dashboard_password password_hash = hashlib.sha256(dashboard_password.encode()).hexdigest()
aisbf_config['dashboard']['password'] = password_hash
aisbf_config['internal_model']['model_id'] = internal_model_id aisbf_config['internal_model']['model_id'] = internal_model_id
# Save config # Save config
...@@ -752,6 +764,47 @@ async def dashboard_settings_save( ...@@ -752,6 +764,47 @@ async def dashboard_settings_save(
"success": "Settings saved successfully! Restart server for changes to take effect." "success": "Settings saved successfully! Restart server for changes to take effect."
}) })
@app.post("/dashboard/restart")
async def dashboard_restart(request: Request):
"""Restart the server"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
import os
import signal
logger.info("Server restart requested from dashboard")
# Schedule restart after response is sent
def restart_server():
import time
time.sleep(1) # Give time for response to be sent
logger.info("Restarting server...")
os.execv(sys.executable, [sys.executable] + _original_argv)
import threading
threading.Thread(target=restart_server, daemon=True).start()
return JSONResponse({"message": "Server is restarting..."})
def parse_provider_from_model(model: str) -> tuple[str, str]:
"""
Parse provider and model from model field.
Supports formats:
- "provider/model" -> ("provider", "model")
- "provider/namespace/model" -> ("provider", "namespace/model")
- "model" -> (None, "model")
Returns:
tuple: (provider_id, actual_model_name)
"""
if '/' in model:
parts = model.split('/', 1)
return parts[0], parts[1]
return None, model
@app.get("/") @app.get("/")
async def root(): async def root():
return { return {
...@@ -761,6 +814,173 @@ async def root(): ...@@ -761,6 +814,173 @@ async def root():
"autoselect": list(config.autoselect.keys()) "autoselect": list(config.autoselect.keys())
} }
# Standard OpenAI-compatible v1 endpoints
@app.post("/api/v1/chat/completions")
async def v1_chat_completions(request: Request, body: ChatCompletionRequest):
"""Standard OpenAI-compatible chat completions endpoint"""
logger.info(f"=== V1 CHAT COMPLETION REQUEST ===")
logger.info(f"Model: {body.model}")
# Parse provider from model field
provider_id, actual_model = parse_provider_from_model(body.model)
if not provider_id:
raise HTTPException(
status_code=400,
detail="Model must be in format 'provider/model' (e.g., 'openai/gpt-4')"
)
logger.info(f"Parsed provider: {provider_id}, model: {actual_model}")
# Update body with actual model name
body_dict = body.model_dump()
body_dict['model'] = actual_model
# Check if it's an autoselect
if provider_id in config.autoselect:
if body.stream:
return await autoselect_handler.handle_autoselect_streaming_request(provider_id, body_dict)
else:
return await autoselect_handler.handle_autoselect_request(provider_id, body_dict)
# Check if it's a rotation
if provider_id in config.rotations:
return await rotation_handler.handle_rotation_request(provider_id, body_dict)
# Check if it's a provider
if provider_id not in config.providers:
raise HTTPException(
status_code=400,
detail=f"Provider '{provider_id}' not found. Available: {list(config.providers.keys())}"
)
# Handle as direct provider request
if body.stream:
return await request_handler.handle_streaming_chat_completion(request, provider_id, body_dict)
else:
return await request_handler.handle_chat_completion(request, provider_id, body_dict)
@app.get("/api/v1/models")
async def v1_list_all_models(request: Request):
"""List all available models from all providers"""
logger.info("=== V1 LIST ALL MODELS REQUEST ===")
all_models = []
# Add provider models
for provider_id in config.providers.keys():
try:
models = await request_handler.handle_model_list(request, provider_id)
for model in models:
# Prepend provider to model ID
model['id'] = f"{provider_id}/{model.get('id', model.get('name', ''))}"
model['provider'] = provider_id
all_models.append(model)
except Exception as e:
logger.warning(f"Error listing models for provider {provider_id}: {e}")
# Add rotation models
for rotation_id in config.rotations.keys():
try:
models = await rotation_handler.handle_rotation_model_list(rotation_id)
for model in models:
model['id'] = f"{rotation_id}/{model.get('name', '')}"
model['type'] = 'rotation'
all_models.append(model)
except Exception as e:
logger.warning(f"Error listing models for rotation {rotation_id}: {e}")
# Add autoselect models
for autoselect_id in config.autoselect.keys():
try:
models = await autoselect_handler.handle_autoselect_model_list(autoselect_id)
for model in models:
model['id'] = f"{autoselect_id}/{model.get('name', model.get('id', ''))}"
model['type'] = 'autoselect'
all_models.append(model)
except Exception as e:
logger.warning(f"Error listing models for autoselect {autoselect_id}: {e}")
return {"object": "list", "data": all_models}
@app.post("/api/v1/audio/transcriptions")
async def v1_audio_transcriptions(request: Request):
"""Standard audio transcription endpoint"""
logger.info("=== V1 AUDIO TRANSCRIPTION REQUEST ===")
form = await request.form()
model = form.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(
status_code=400,
detail="Model must be in format 'provider/model' (e.g., 'openai/whisper-1')"
)
# Create new form data with updated model
from starlette.datastructures import FormData
updated_form = FormData()
for key, value in form.items():
if key == 'model':
updated_form[key] = actual_model
else:
updated_form[key] = value
return await request_handler.handle_audio_transcription(request, provider_id, updated_form)
@app.post("/api/v1/audio/speech")
async def v1_audio_speech(request: Request, body: dict):
"""Standard text-to-speech endpoint"""
logger.info("=== V1 TEXT-TO-SPEECH REQUEST ===")
model = body.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(
status_code=400,
detail="Model must be in format 'provider/model' (e.g., 'openai/tts-1')"
)
body['model'] = actual_model
return await request_handler.handle_text_to_speech(request, provider_id, body)
@app.post("/api/v1/images/generations")
async def v1_image_generations(request: Request, body: dict):
"""Standard image generation endpoint"""
logger.info("=== V1 IMAGE GENERATION REQUEST ===")
model = body.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(
status_code=400,
detail="Model must be in format 'provider/model' (e.g., 'openai/dall-e-3')"
)
body['model'] = actual_model
return await request_handler.handle_image_generation(request, provider_id, body)
@app.post("/api/v1/embeddings")
async def v1_embeddings(request: Request, body: dict):
"""Standard embeddings endpoint"""
logger.info("=== V1 EMBEDDINGS REQUEST ===")
model = body.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(
status_code=400,
detail="Model must be in format 'provider/model' (e.g., 'openai/text-embedding-ada-002')"
)
body['model'] = actual_model
return await request_handler.handle_embeddings(request, provider_id, body)
@app.get("/api/rotations") @app.get("/api/rotations")
async def list_rotations(): async def list_rotations():
"""List all available rotations""" """List all available rotations"""
...@@ -1034,6 +1254,78 @@ async def list_models(request: Request, provider_id: str): ...@@ -1034,6 +1254,78 @@ async def list_models(request: Request, provider_id: str):
logger.error(f"Error handling list_models: {str(e)}", exc_info=True) logger.error(f"Error handling list_models: {str(e)}", exc_info=True)
raise raise
# Audio endpoints
@app.post("/api/{provider_id}/audio/transcriptions")
async def audio_transcriptions(provider_id: str, request: Request):
"""Handle audio transcription requests"""
logger.info(f"=== AUDIO TRANSCRIPTION REQUEST ===")
logger.info(f"Provider ID: {provider_id}")
# Get form data (audio file upload)
form = await request.form()
try:
result = await request_handler.handle_audio_transcription(request, provider_id, form)
return result
except Exception as e:
logger.error(f"Error handling audio transcription: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/{provider_id}/audio/speech")
async def audio_speech(provider_id: str, request: Request, body: dict):
"""Handle text-to-speech requests"""
logger.info(f"=== TEXT-TO-SPEECH REQUEST ===")
logger.info(f"Provider ID: {provider_id}")
try:
result = await request_handler.handle_text_to_speech(request, provider_id, body)
return result
except Exception as e:
logger.error(f"Error handling text-to-speech: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# Image endpoints
@app.post("/api/{provider_id}/images/generations")
async def image_generations(provider_id: str, request: Request, body: dict):
"""Handle image generation requests"""
logger.info(f"=== IMAGE GENERATION REQUEST ===")
logger.info(f"Provider ID: {provider_id}")
try:
result = await request_handler.handle_image_generation(request, provider_id, body)
return result
except Exception as e:
logger.error(f"Error handling image generation: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# Embeddings endpoint
@app.post("/api/{provider_id}/embeddings")
async def embeddings(provider_id: str, request: Request, body: dict):
"""Handle embeddings requests"""
logger.info(f"=== EMBEDDINGS REQUEST ===")
logger.info(f"Provider ID: {provider_id}")
try:
result = await request_handler.handle_embeddings(request, provider_id, body)
return result
except Exception as e:
logger.error(f"Error handling embeddings: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# Content proxy endpoint
@app.get("/api/proxy/{content_id}")
async def proxy_content(content_id: str):
"""Proxy generated content (images, audio, etc.)"""
logger.info(f"=== PROXY CONTENT REQUEST ===")
logger.info(f"Content ID: {content_id}")
try:
result = await request_handler.handle_content_proxy(content_id)
return result
except Exception as e:
logger.error(f"Error proxying content: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/{provider_id}") @app.post("/api/{provider_id}")
async def catch_all_post(provider_id: str, request: Request): async def catch_all_post(provider_id: str, request: Request):
"""Catch-all for POST requests to help debug routing issues""" """Catch-all for POST requests to help debug routing issues"""
...@@ -1086,6 +1378,10 @@ Examples: ...@@ -1086,6 +1378,10 @@ Examples:
args = parser.parse_args() args = parser.parse_args()
# Store original command line arguments for restart functionality
global _original_argv
_original_argv = sys.argv.copy()
# Set custom config directory if provided # Set custom config directory if provided
if args.config: if args.config:
set_config_dir(args.config) set_config_dir(args.config)
......
...@@ -86,6 +86,7 @@ setup( ...@@ -86,6 +86,7 @@ setup(
'config/rotations.json', 'config/rotations.json',
'config/autoselect.json', 'config/autoselect.json',
'config/autoselect.md', 'config/autoselect.md',
'config/aisbf.json',
]), ]),
# Install aisbf package to share directory for venv installation # Install aisbf package to share directory for venv installation
('share/aisbf/aisbf', [ ('share/aisbf/aisbf', [
...@@ -98,6 +99,16 @@ setup( ...@@ -98,6 +99,16 @@ setup(
'aisbf/utils.py', 'aisbf/utils.py',
'aisbf/database.py', 'aisbf/database.py',
]), ]),
# Install dashboard templates
('share/aisbf/templates', [
'templates/base.html',
]),
('share/aisbf/templates/dashboard', [
'templates/dashboard/login.html',
'templates/dashboard/index.html',
'templates/dashboard/edit_config.html',
'templates/dashboard/settings.html',
]),
], ],
entry_points={ entry_points={
"console_scripts": [ "console_scripts": [
......
<!--
Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
...@@ -9,7 +25,8 @@ ...@@ -9,7 +25,8 @@
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: #f5f5f5; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: #f5f5f5; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; } .container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.header { background: #2c3e50; color: white; padding: 20px 0; margin-bottom: 30px; } .header { background: #2c3e50; color: white; padding: 20px 0; margin-bottom: 30px; }
.header h1 { font-size: 24px; font-weight: 600; } .header h1 { font-size: 24px; font-weight: 600; display: inline-block; }
.header-actions { float: right; }
.nav { background: white; padding: 15px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .nav { background: white; padding: 15px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.nav a { color: #2c3e50; text-decoration: none; margin-right: 20px; padding: 8px 12px; border-radius: 4px; } .nav a { color: #2c3e50; text-decoration: none; margin-right: 20px; padding: 8px 12px; border-radius: 4px; }
.nav a:hover { background: #ecf0f1; } .nav a:hover { background: #ecf0f1; }
...@@ -19,28 +36,67 @@ ...@@ -19,28 +36,67 @@
.form-group label { display: block; margin-bottom: 5px; font-weight: 500; color: #2c3e50; } .form-group label { display: block; margin-bottom: 5px; font-weight: 500; color: #2c3e50; }
.form-group input, .form-group textarea, .form-group select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; } .form-group input, .form-group textarea, .form-group select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
.form-group textarea { min-height: 200px; font-family: 'Courier New', monospace; } .form-group textarea { min-height: 200px; font-family: 'Courier New', monospace; }
.btn { padding: 10px 20px; background: #3498db; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; } .btn { padding: 10px 20px; background: #3498db; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; text-decoration: none; display: inline-block; margin-left: 10px; }
.btn:hover { background: #2980b9; } .btn:hover { background: #2980b9; }
.btn-secondary { background: #95a5a6; } .btn-secondary { background: #95a5a6; }
.btn-secondary:hover { background: #7f8c8d; } .btn-secondary:hover { background: #7f8c8d; }
.btn-danger { background: #e74c3c; } .btn-danger { background: #e74c3c; }
.btn-danger:hover { background: #c0392b; } .btn-danger:hover { background: #c0392b; }
.btn-warning { background: #f39c12; }
.btn-warning:hover { background: #e67e22; }
.alert { padding: 15px; border-radius: 4px; margin-bottom: 20px; } .alert { padding: 15px; border-radius: 4px; margin-bottom: 20px; }
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.alert-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } .alert-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.logout { float: right; } .alert-info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; } table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; } th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
th { background: #f8f9fa; font-weight: 600; } th { background: #f8f9fa; font-weight: 600; }
.code { background: #f8f9fa; padding: 15px; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 13px; overflow-x: auto; } .code { background: #f8f9fa; padding: 15px; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 13px; overflow-x: auto; }
</style> </style>
<script>
/*
* Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
function restartServer() {
if (confirm('Are you sure you want to restart the server? This will disconnect all active connections.')) {
fetch('/dashboard/restart', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(response => response.json())
.then(data => {
alert(data.message + ' The page will reload in 5 seconds.');
setTimeout(() => window.location.reload(), 5000);
})
.catch(error => {
alert('Error restarting server: ' + error);
});
}
}
</script>
</head> </head>
<body> <body>
<div class="header"> <div class="header">
<div class="container"> <div class="container">
<h1>AISBF Dashboard</h1> <h1>AISBF Dashboard</h1>
{% if session.logged_in %} {% if session.logged_in %}
<a href="/dashboard/logout" class="btn btn-secondary logout">Logout</a> <div class="header-actions">
<button onclick="restartServer()" class="btn btn-warning">Restart Server</button>
<a href="/dashboard/logout" class="btn btn-secondary">Logout</a>
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
......
<!--
Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ title }} - AISBF Dashboard{% endblock %} {% block title %}{{ title }} - AISBF Dashboard{% endblock %}
......
<!--
Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Overview - AISBF Dashboard{% endblock %} {% block title %}Overview - AISBF Dashboard{% endblock %}
......
<!--
Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Login - AISBF Dashboard{% endblock %} {% block title %}Login - AISBF Dashboard{% endblock %}
......
<!--
Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Settings - AISBF Dashboard{% endblock %} {% block title %}Settings - AISBF Dashboard{% endblock %}
......
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