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
include cli.py
recursive-include config *.json
recursive-include config *.md
recursive-include aisbf *.py
\ No newline at end of file
recursive-include aisbf *.py
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
### General Endpoints
- `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
- `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
- `GET /api/rotations` - List all available rotation configurations
......@@ -131,6 +148,17 @@ See `config/providers.json` and `config/rotations.json` for configuration exampl
- Supports both streaming and non-streaming responses
- `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
- Rate limiting for failed requests
- Automatic retry with provider rotation
......
......@@ -41,6 +41,37 @@ fi
# Create log directory if it doesn't exist
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
ensure_venv() {
if [ ! -d "$VENV_DIR" ]; then
......@@ -79,15 +110,20 @@ start_server() {
# Update venv packages silently
update_venv
# Get port from config
PORT=$(get_port)
# Activate the virtual environment
source $VENV_DIR/bin/activate
# Change to share directory where main.py is located
cd $SHARE_DIR
echo "Starting AISBF on port $PORT..."
# Start the proxy server with logging
# 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
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"
......@@ -115,9 +151,14 @@ start_daemon() {
# Update venv packages silently
update_venv
# Get port from config
PORT=$(get_port)
echo "Starting AISBF on port $PORT in background..."
# Start in background with nohup and logging
# 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=$!
echo $PID > "$PIDFILE"
echo "AISBF started in background (PID: $PID)"
......
......@@ -648,9 +648,438 @@ class RequestHandler:
await handler.apply_rate_limit()
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:
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:
def __init__(self):
......@@ -1940,16 +2369,127 @@ class RotationHandler:
all_models = []
for provider in rotation_config.providers:
provider_id = provider['provider_id']
provider_config = self.config.get_provider(provider_id)
for model in provider['models']:
all_models.append({
"id": f"{provider['provider_id']}/{model['name']}",
"name": model['name'],
"provider_id": provider['provider_id'],
model_name = model['name']
model_dict = {
"id": f"{provider_id}/{model_name}",
"name": model_name,
"provider_id": provider_id,
"weight": model['weight'],
"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
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:
def __init__(self):
......
......@@ -49,6 +49,180 @@ class BaseProviderHandler:
self.model_last_request_time = {} # {model_name: timestamp}
# Token usage tracking for rate limits
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:
if self.error_tracking['disabled_until'] and self.error_tracking['disabled_until'] > time.time():
......@@ -1329,6 +1503,19 @@ class KiroProviderHandler(BaseProviderHandler):
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_data = response.json()
......@@ -1534,6 +1721,20 @@ class OllamaProviderHandler(BaseProviderHandler):
logger.info(f"Response content type: {response.headers.get('content-type')}")
logger.info(f"Response content length: {len(response.content)} bytes")
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()
# Ollama may return multiple JSON objects, parse them all
......
......@@ -16,7 +16,7 @@
"dashboard": {
"enabled": true,
"username": "admin",
"password": "admin"
"password": "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918"
},
"internal_model": {
"model_id": "huihui-ai/Qwen2.5-0.5B-Instruct-abliterated-v3"
......
......@@ -24,7 +24,8 @@
"rate_limit_TPD": 1000000,
"context_size": 1000000,
"condense_context": 80,
"condense_method": ["hierarchical", "semantic"]
"condense_method": ["hierarchical", "semantic"],
"capabilities": ["t2t", "vision", "function_calling"]
},
{
"name": "gemini-1.5-pro",
......@@ -35,7 +36,8 @@
"rate_limit_TPD": 1000000,
"context_size": 2000000,
"condense_context": 85,
"condense_method": "conversational"
"condense_method": "conversational",
"capabilities": ["t2t", "vision", "function_calling"]
}
]
},
......
......@@ -38,6 +38,7 @@ import sys
import os
import argparse
import secrets
import hashlib
from logging.handlers import RotatingFileHandler
from datetime import datetime, timedelta
from collections import defaultdict
......@@ -47,6 +48,9 @@ import json
# Global variable to store custom config directory
_custom_config_dir = None
# Global variable to store original command line arguments for restart
_original_argv = None
def set_config_dir(config_dir: str):
"""Set custom config directory before importing config"""
global _custom_config_dir
......@@ -449,7 +453,14 @@ async def dashboard_login_page(request: Request):
async def dashboard_login(request: Request, username: str = Form(...), password: str = Form(...)):
"""Handle dashboard login"""
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['username'] = username
return RedirectResponse(url="/dashboard", status_code=303)
......@@ -735,8 +746,9 @@ async def dashboard_settings_save(
aisbf_config['auth']['enabled'] = auth_enabled
aisbf_config['auth']['tokens'] = [t.strip() for t in auth_tokens.split('\n') if t.strip()]
aisbf_config['dashboard']['username'] = dashboard_username
if dashboard_password: # Only update if provided
aisbf_config['dashboard']['password'] = dashboard_password
if dashboard_password: # Only update if provided - hash the password
password_hash = hashlib.sha256(dashboard_password.encode()).hexdigest()
aisbf_config['dashboard']['password'] = password_hash
aisbf_config['internal_model']['model_id'] = internal_model_id
# Save config
......@@ -752,6 +764,47 @@ async def dashboard_settings_save(
"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("/")
async def root():
return {
......@@ -761,6 +814,173 @@ async def root():
"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")
async def list_rotations():
"""List all available rotations"""
......@@ -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)
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}")
async def catch_all_post(provider_id: str, request: Request):
"""Catch-all for POST requests to help debug routing issues"""
......@@ -1086,6 +1378,10 @@ Examples:
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
if args.config:
set_config_dir(args.config)
......
......@@ -86,6 +86,7 @@ setup(
'config/rotations.json',
'config/autoselect.json',
'config/autoselect.md',
'config/aisbf.json',
]),
# Install aisbf package to share directory for venv installation
('share/aisbf/aisbf', [
......@@ -98,6 +99,16 @@ setup(
'aisbf/utils.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={
"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>
<html lang="en">
<head>
......@@ -9,7 +25,8 @@
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; }
.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 a { color: #2c3e50; text-decoration: none; margin-right: 20px; padding: 8px 12px; border-radius: 4px; }
.nav a:hover { background: #ecf0f1; }
......@@ -19,28 +36,67 @@
.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 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-secondary { background: #95a5a6; }
.btn-secondary:hover { background: #7f8c8d; }
.btn-danger { background: #e74c3c; }
.btn-danger:hover { background: #c0392b; }
.btn-warning { background: #f39c12; }
.btn-warning:hover { background: #e67e22; }
.alert { padding: 15px; border-radius: 4px; margin-bottom: 20px; }
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.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; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
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; }
</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>
<body>
<div class="header">
<div class="container">
<h1>AISBF Dashboard</h1>
{% 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 %}
</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" %}
{% 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" %}
{% 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" %}
{% 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" %}
{% 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