chore: initial clean commit
This commit is contained in:
22
.env
Normal file
22
.env
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# LLM Proxy Gateway Environment Variables
|
||||||
|
|
||||||
|
# OpenAI
|
||||||
|
OPENAI_API_KEY=sk-demo-openai-key
|
||||||
|
|
||||||
|
# Google Gemini
|
||||||
|
GEMINI_API_KEY=AIza-demo-gemini-key
|
||||||
|
|
||||||
|
# DeepSeek
|
||||||
|
DEEPSEEK_API_KEY=sk-demo-deepseek-key
|
||||||
|
|
||||||
|
# xAI Grok (not yet available)
|
||||||
|
GROK_API_KEY=gk-demo-grok-key
|
||||||
|
|
||||||
|
# Authentication tokens (comma-separated list)
|
||||||
|
LLM_PROXY__SERVER__AUTH_TOKENS=demo-token-123456,another-token
|
||||||
|
|
||||||
|
# Server port (optional)
|
||||||
|
LLM_PROXY__SERVER__PORT=8080
|
||||||
|
|
||||||
|
# Database path (optional)
|
||||||
|
LLM_PROXY__DATABASE__PATH=./data/llm_proxy.db
|
||||||
28
.env.example
Normal file
28
.env.example
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# LLM Proxy Gateway Environment Variables
|
||||||
|
# Copy to .env and fill in your API keys
|
||||||
|
|
||||||
|
# OpenAI
|
||||||
|
OPENAI_API_KEY=your_openai_api_key_here
|
||||||
|
|
||||||
|
# Google Gemini
|
||||||
|
GEMINI_API_KEY=your_gemini_api_key_here
|
||||||
|
|
||||||
|
# DeepSeek
|
||||||
|
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
||||||
|
|
||||||
|
# xAI Grok (not yet available)
|
||||||
|
GROK_API_KEY=your_grok_api_key_here
|
||||||
|
|
||||||
|
# Ollama (local server)
|
||||||
|
# LLM_PROXY__PROVIDERS__OLLAMA__BASE_URL=http://your-ollama-host:11434/v1
|
||||||
|
# LLM_PROXY__PROVIDERS__OLLAMA__ENABLED=true
|
||||||
|
# LLM_PROXY__PROVIDERS__OLLAMA__MODELS=llama3,mistral,llava
|
||||||
|
|
||||||
|
# Authentication tokens (comma-separated list)
|
||||||
|
LLM_PROXY__SERVER__AUTH_TOKENS=your_bearer_token_here,another_token
|
||||||
|
|
||||||
|
# Server port (optional)
|
||||||
|
LLM_PROXY__SERVER__PORT=8080
|
||||||
|
|
||||||
|
# Database path (optional)
|
||||||
|
LLM_PROXY__DATABASE__PATH=./data/llm_proxy.db
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/target
|
||||||
|
/.env
|
||||||
|
/*.db
|
||||||
|
/*.db-shm
|
||||||
|
/*.db-wal
|
||||||
4163
Cargo.lock
generated
Normal file
4163
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
75
Cargo.toml
Normal file
75
Cargo.toml
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
[package]
|
||||||
|
name = "llm-proxy"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Unified LLM proxy gateway supporting OpenAI, Gemini, DeepSeek, and Grok with token tracking and cost calculation"
|
||||||
|
authors = ["newkirk"]
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = ""
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# ========== Web Framework & Async Runtime ==========
|
||||||
|
axum = { version = "0.8", features = ["macros", "ws"] }
|
||||||
|
tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "net", "time", "signal", "fs"] }
|
||||||
|
tower = "0.5"
|
||||||
|
tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip", "fs"] }
|
||||||
|
|
||||||
|
# ========== HTTP Clients ==========
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||||
|
async-openai = { version = "0.33", default-features = false, features = ["_api", "chat-completion"] }
|
||||||
|
tiktoken-rs = "0.9"
|
||||||
|
|
||||||
|
# ========== Database & ORM ==========
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "macros", "migrate", "chrono"] }
|
||||||
|
|
||||||
|
# ========== Authentication & Middleware ==========
|
||||||
|
axum-extra = { version = "0.12", features = ["typed-header"] }
|
||||||
|
headers = "0.4"
|
||||||
|
|
||||||
|
# ========== Configuration Management ==========
|
||||||
|
config = "0.13"
|
||||||
|
dotenvy = "0.15"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
|
# ========== Logging & Monitoring ==========
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
|
||||||
|
# ========== Multimodal & Image Processing ==========
|
||||||
|
base64 = "0.21"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||||
|
mime = "0.3"
|
||||||
|
mime_guess = "2.0"
|
||||||
|
|
||||||
|
# ========== Error Handling & Utilities ==========
|
||||||
|
anyhow = "1.0"
|
||||||
|
thiserror = "1.0"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
|
futures = "0.3"
|
||||||
|
async-trait = "0.1"
|
||||||
|
async-stream = "0.3"
|
||||||
|
reqwest-eventsource = "0.6"
|
||||||
|
once_cell = "1.19"
|
||||||
|
regex = "1.10"
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
|
# ========== Rate Limiting & Circuit Breaking ==========
|
||||||
|
governor = "0.6"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio-test = "0.4"
|
||||||
|
mockito = "1.0"
|
||||||
|
tempfile = "3.10"
|
||||||
|
assert_cmd = "2.0"
|
||||||
|
insta = "1.39"
|
||||||
|
anyhow = "1.0"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
|
panic = "abort"
|
||||||
207
DASHBOARD_README.md
Normal file
207
DASHBOARD_README.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
# LLM Proxy Gateway - Admin Dashboard
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This is a comprehensive admin dashboard for the LLM Proxy Gateway, providing real-time monitoring, analytics, and management capabilities for the proxy service.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 1. **Dashboard Overview**
|
||||||
|
- Real-time request counters and statistics
|
||||||
|
- System health indicators
|
||||||
|
- Provider status monitoring
|
||||||
|
- Recent requests stream
|
||||||
|
|
||||||
|
### 2. **Usage Analytics**
|
||||||
|
- Time series charts for requests, tokens, and costs
|
||||||
|
- Filter by date range, client, provider, and model
|
||||||
|
- Top clients and models analysis
|
||||||
|
- Export functionality to CSV/JSON
|
||||||
|
|
||||||
|
### 3. **Cost Management**
|
||||||
|
- Cost breakdown by provider, client, and model
|
||||||
|
- Budget tracking with alerts
|
||||||
|
- Cost projections
|
||||||
|
- Pricing configuration management
|
||||||
|
|
||||||
|
### 4. **Client Management**
|
||||||
|
- List, create, revoke, and rotate API tokens
|
||||||
|
- Client-specific rate limits
|
||||||
|
- Usage statistics per client
|
||||||
|
- Token management interface
|
||||||
|
|
||||||
|
### 5. **Provider Configuration**
|
||||||
|
- Enable/disable LLM providers
|
||||||
|
- Configure API keys (masked display)
|
||||||
|
- Test provider connections
|
||||||
|
- Model availability management
|
||||||
|
|
||||||
|
### 6. **Real-time Monitoring**
|
||||||
|
- Live request stream via WebSocket
|
||||||
|
- System metrics dashboard
|
||||||
|
- Response time and error rate tracking
|
||||||
|
- Live system logs
|
||||||
|
|
||||||
|
### 7. **System Settings**
|
||||||
|
- General configuration
|
||||||
|
- Database management
|
||||||
|
- Logging settings
|
||||||
|
- Security settings
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **HTML5/CSS3**: Modern, responsive design with CSS Grid/Flexbox
|
||||||
|
- **JavaScript (ES6+)**: Vanilla JavaScript with modular architecture
|
||||||
|
- **Chart.js**: Interactive data visualizations
|
||||||
|
- **Luxon**: Date/time manipulation
|
||||||
|
- **WebSocket API**: Real-time updates
|
||||||
|
|
||||||
|
### Backend (Rust/Axum)
|
||||||
|
- **Axum**: Web framework with WebSocket support
|
||||||
|
- **Tokio**: Async runtime
|
||||||
|
- **Serde**: JSON serialization/deserialization
|
||||||
|
- **Broadcast channels**: Real-time event distribution
|
||||||
|
|
||||||
|
## Installation & Setup
|
||||||
|
|
||||||
|
### 1. Build and Run the Server
|
||||||
|
```bash
|
||||||
|
# Build the project
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Run the server
|
||||||
|
cargo run --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Access the Dashboard
|
||||||
|
Once the server is running, access the dashboard at:
|
||||||
|
```
|
||||||
|
http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Default Login Credentials
|
||||||
|
- **Username**: `admin`
|
||||||
|
- **Password**: `admin123`
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `POST /api/auth/login` - Dashboard login
|
||||||
|
- `GET /api/auth/status` - Authentication status
|
||||||
|
|
||||||
|
### Analytics
|
||||||
|
- `GET /api/usage/summary` - Overall usage summary
|
||||||
|
- `GET /api/usage/time-series` - Time series data
|
||||||
|
- `GET /api/usage/clients` - Client breakdown
|
||||||
|
- `GET /api/usage/providers` - Provider breakdown
|
||||||
|
|
||||||
|
### Clients
|
||||||
|
- `GET /api/clients` - List all clients
|
||||||
|
- `POST /api/clients` - Create new client
|
||||||
|
- `DELETE /api/clients/{id}` - Revoke client
|
||||||
|
- `GET /api/clients/{id}/usage` - Client-specific usage
|
||||||
|
|
||||||
|
### Providers
|
||||||
|
- `GET /api/providers` - List providers and status
|
||||||
|
- `PUT /api/providers/{name}` - Update provider config
|
||||||
|
- `POST /api/providers/{name}/test` - Test provider connection
|
||||||
|
|
||||||
|
### System
|
||||||
|
- `GET /api/system/health` - System health
|
||||||
|
- `GET /api/system/logs` - Recent logs
|
||||||
|
- `POST /api/system/backup` - Trigger backup
|
||||||
|
|
||||||
|
### WebSocket
|
||||||
|
- `GET /ws` - WebSocket endpoint for real-time updates
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
llm-proxy/
|
||||||
|
├── src/
|
||||||
|
│ ├── dashboard/ # Dashboard backend module
|
||||||
|
│ │ └── mod.rs # Dashboard routes and handlers
|
||||||
|
│ ├── server/ # Main proxy server
|
||||||
|
│ ├── providers/ # LLM provider implementations
|
||||||
|
│ └── ... # Other modules
|
||||||
|
├── static/ # Frontend dashboard files
|
||||||
|
│ ├── index.html # Main dashboard HTML
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── dashboard.css # Dashboard styles
|
||||||
|
│ ├── js/
|
||||||
|
│ │ ├── auth.js # Authentication module
|
||||||
|
│ │ ├── dashboard.js # Main dashboard controller
|
||||||
|
│ │ ├── websocket.js # WebSocket manager
|
||||||
|
│ │ ├── charts.js # Chart.js utilities
|
||||||
|
│ │ └── pages/ # Page-specific modules
|
||||||
|
│ │ ├── overview.js
|
||||||
|
│ │ ├── analytics.js
|
||||||
|
│ │ ├── costs.js
|
||||||
|
│ │ ├── clients.js
|
||||||
|
│ │ ├── providers.js
|
||||||
|
│ │ ├── monitoring.js
|
||||||
|
│ │ ├── settings.js
|
||||||
|
│ │ └── logs.js
|
||||||
|
│ ├── img/ # Images and icons
|
||||||
|
│ └── fonts/ # Font files
|
||||||
|
└── Cargo.toml # Rust dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Adding New Pages
|
||||||
|
1. Create a new JavaScript module in `static/js/pages/`
|
||||||
|
2. Implement the page class with `init()` method
|
||||||
|
3. Register the page in `dashboard.js`
|
||||||
|
4. Add menu item in `index.html`
|
||||||
|
|
||||||
|
### Adding New API Endpoints
|
||||||
|
1. Add route in `src/dashboard/mod.rs`
|
||||||
|
2. Implement handler function
|
||||||
|
3. Update frontend JavaScript to call the endpoint
|
||||||
|
|
||||||
|
### Styling Guidelines
|
||||||
|
- Use CSS custom properties (variables) from `:root`
|
||||||
|
- Follow mobile-first responsive design
|
||||||
|
- Use BEM-like naming convention for CSS classes
|
||||||
|
- Maintain consistent spacing with CSS variables
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Authentication**: Simple password-based auth for demo; replace with proper auth in production
|
||||||
|
2. **API Keys**: Tokens are masked in the UI (only last 4 characters shown)
|
||||||
|
3. **CORS**: Configure appropriate CORS headers for production
|
||||||
|
4. **Rate Limiting**: Implement rate limiting for API endpoints
|
||||||
|
5. **HTTPS**: Always use HTTPS in production
|
||||||
|
|
||||||
|
## Performance Optimizations
|
||||||
|
|
||||||
|
1. **Code Splitting**: JavaScript modules are loaded on-demand
|
||||||
|
2. **Caching**: Static assets are served with cache headers
|
||||||
|
3. **WebSocket**: Real-time updates reduce polling overhead
|
||||||
|
4. **Lazy Loading**: Charts and tables load data as needed
|
||||||
|
5. **Compression**: Enable gzip/brotli compression for static files
|
||||||
|
|
||||||
|
## Browser Support
|
||||||
|
|
||||||
|
- Chrome 60+
|
||||||
|
- Firefox 55+
|
||||||
|
- Safari 11+
|
||||||
|
- Edge 79+
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - See LICENSE file for details.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Add tests if applicable
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and feature requests, please use the GitHub issue tracker.
|
||||||
232
OPTIMIZATION.md
Normal file
232
OPTIMIZATION.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# Optimization for 512MB RAM Environment
|
||||||
|
|
||||||
|
This document provides guidance for optimizing the LLM Proxy Gateway for deployment in resource-constrained environments (512MB RAM).
|
||||||
|
|
||||||
|
## Memory Optimization Strategies
|
||||||
|
|
||||||
|
### 1. Build Optimization
|
||||||
|
|
||||||
|
The project is already configured with optimized build settings in `Cargo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3 # Maximum optimization
|
||||||
|
lto = true # Link-time optimization
|
||||||
|
codegen-units = 1 # Single codegen unit for better optimization
|
||||||
|
strip = true # Strip debug symbols
|
||||||
|
```
|
||||||
|
|
||||||
|
**Additional optimizations you can apply:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build with specific target for better optimization
|
||||||
|
cargo build --release --target x86_64-unknown-linux-musl
|
||||||
|
|
||||||
|
# Or for ARM (Raspberry Pi, etc.)
|
||||||
|
cargo build --release --target aarch64-unknown-linux-musl
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Runtime Memory Management
|
||||||
|
|
||||||
|
#### Database Connection Pool
|
||||||
|
- Default: 10 connections
|
||||||
|
- Recommended for 512MB: 5 connections
|
||||||
|
|
||||||
|
Update `config.toml`:
|
||||||
|
```toml
|
||||||
|
[database]
|
||||||
|
max_connections = 5
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Rate Limiting Memory Usage
|
||||||
|
- Client rate limit buckets: Store in memory
|
||||||
|
- Circuit breakers: Minimal memory usage
|
||||||
|
- Consider reducing burst capacity if memory is critical
|
||||||
|
|
||||||
|
#### Provider Management
|
||||||
|
- Only enable providers you actually use
|
||||||
|
- Disable unused providers in configuration
|
||||||
|
|
||||||
|
### 3. Configuration for Low Memory
|
||||||
|
|
||||||
|
Create a `config-low-memory.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[server]
|
||||||
|
port = 8080
|
||||||
|
host = "0.0.0.0"
|
||||||
|
|
||||||
|
[database]
|
||||||
|
path = "./data/llm_proxy.db"
|
||||||
|
max_connections = 3 # Reduced from default 10
|
||||||
|
|
||||||
|
[providers]
|
||||||
|
# Only enable providers you need
|
||||||
|
openai.enabled = true
|
||||||
|
gemini.enabled = false # Disable if not used
|
||||||
|
deepseek.enabled = false # Disable if not used
|
||||||
|
grok.enabled = false # Disable if not used
|
||||||
|
|
||||||
|
[rate_limiting]
|
||||||
|
# Reduce memory usage for rate limiting
|
||||||
|
client_requests_per_minute = 30 # Reduced from 60
|
||||||
|
client_burst_size = 5 # Reduced from 10
|
||||||
|
global_requests_per_minute = 300 # Reduced from 600
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. System-Level Optimizations
|
||||||
|
|
||||||
|
#### Linux Kernel Parameters
|
||||||
|
Add to `/etc/sysctl.conf`:
|
||||||
|
```bash
|
||||||
|
# Reduce TCP buffer sizes
|
||||||
|
net.ipv4.tcp_rmem = 4096 87380 174760
|
||||||
|
net.ipv4.tcp_wmem = 4096 65536 131072
|
||||||
|
|
||||||
|
# Reduce connection tracking
|
||||||
|
net.netfilter.nf_conntrack_max = 65536
|
||||||
|
net.netfilter.nf_conntrack_tcp_timeout_established = 1200
|
||||||
|
|
||||||
|
# Reduce socket buffer sizes
|
||||||
|
net.core.rmem_max = 131072
|
||||||
|
net.core.wmem_max = 131072
|
||||||
|
net.core.rmem_default = 65536
|
||||||
|
net.core.wmem_default = 65536
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Systemd Service Configuration
|
||||||
|
Create `/etc/systemd/system/llm-proxy.service`:
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=LLM Proxy Gateway
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=llmproxy
|
||||||
|
Group=llmproxy
|
||||||
|
WorkingDirectory=/opt/llm-proxy
|
||||||
|
ExecStart=/opt/llm-proxy/llm-proxy
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Memory limits
|
||||||
|
MemoryMax=400M
|
||||||
|
MemorySwapMax=100M
|
||||||
|
|
||||||
|
# CPU limits
|
||||||
|
CPUQuota=50%
|
||||||
|
|
||||||
|
# Process limits
|
||||||
|
LimitNOFILE=65536
|
||||||
|
LimitNPROC=512
|
||||||
|
|
||||||
|
Environment="RUST_LOG=info"
|
||||||
|
Environment="LLM_PROXY__DATABASE__MAX_CONNECTIONS=3"
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Application-Specific Optimizations
|
||||||
|
|
||||||
|
#### Disable Unused Features
|
||||||
|
- **Multimodal support**: If not using images, disable image processing dependencies
|
||||||
|
- **Dashboard**: The dashboard uses WebSockets and additional memory. Consider disabling if not needed.
|
||||||
|
- **Detailed logging**: Reduce log verbosity in production
|
||||||
|
|
||||||
|
#### Memory Pool Sizes
|
||||||
|
The application uses several memory pools:
|
||||||
|
1. **Database connection pool**: Configured via `max_connections`
|
||||||
|
2. **HTTP client pool**: Reqwest client pool (defaults to reasonable values)
|
||||||
|
3. **Async runtime**: Tokio worker threads
|
||||||
|
|
||||||
|
Reduce Tokio worker threads for low-core systems:
|
||||||
|
```rust
|
||||||
|
// In main.rs, modify tokio runtime creation
|
||||||
|
#[tokio::main(flavor = "current_thread")] // Single-threaded runtime
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
// Or for multi-threaded with limited threads:
|
||||||
|
// #[tokio::main(worker_threads = 2)]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Monitoring and Profiling
|
||||||
|
|
||||||
|
#### Memory Usage Monitoring
|
||||||
|
```bash
|
||||||
|
# Install heaptrack for memory profiling
|
||||||
|
cargo install heaptrack
|
||||||
|
|
||||||
|
# Profile memory usage
|
||||||
|
heaptrack ./target/release/llm-proxy
|
||||||
|
|
||||||
|
# Monitor with ps
|
||||||
|
ps aux --sort=-%mem | head -10
|
||||||
|
|
||||||
|
# Monitor with top
|
||||||
|
top -p $(pgrep llm-proxy)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Performance Benchmarks
|
||||||
|
Test with different configurations:
|
||||||
|
```bash
|
||||||
|
# Test with 100 concurrent connections
|
||||||
|
wrk -t4 -c100 -d30s http://localhost:8080/health
|
||||||
|
|
||||||
|
# Test chat completion endpoint
|
||||||
|
ab -n 1000 -c 10 -p test_request.json -T application/json http://localhost:8080/v1/chat/completions
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Deployment Checklist for 512MB RAM
|
||||||
|
|
||||||
|
- [ ] Build with release profile: `cargo build --release`
|
||||||
|
- [ ] Configure database with `max_connections = 3`
|
||||||
|
- [ ] Disable unused providers in configuration
|
||||||
|
- [ ] Set appropriate rate limiting limits
|
||||||
|
- [ ] Configure systemd with memory limits
|
||||||
|
- [ ] Set up log rotation to prevent disk space issues
|
||||||
|
- [ ] Monitor memory usage during initial deployment
|
||||||
|
- [ ] Consider using swap space (512MB-1GB) for safety
|
||||||
|
|
||||||
|
### 8. Troubleshooting High Memory Usage
|
||||||
|
|
||||||
|
#### Common Issues and Solutions:
|
||||||
|
|
||||||
|
1. **Database connection leaks**: Ensure connections are properly closed
|
||||||
|
2. **Memory fragmentation**: Use jemalloc or mimalloc as allocator
|
||||||
|
3. **Unbounded queues**: Check WebSocket message queues
|
||||||
|
4. **Cache growth**: Implement cache limits or TTL
|
||||||
|
|
||||||
|
#### Add to Cargo.toml for alternative allocator:
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
mimalloc = { version = "0.1", default-features = false }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["mimalloc"]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### In main.rs:
|
||||||
|
```rust
|
||||||
|
#[global_allocator]
|
||||||
|
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Expected Memory Usage
|
||||||
|
|
||||||
|
| Component | Baseline | With 10 clients | With 100 clients |
|
||||||
|
|-----------|----------|-----------------|------------------|
|
||||||
|
| Base executable | 15MB | 15MB | 15MB |
|
||||||
|
| Database connections | 5MB | 8MB | 15MB |
|
||||||
|
| Rate limiting | 2MB | 5MB | 20MB |
|
||||||
|
| HTTP clients | 3MB | 5MB | 10MB |
|
||||||
|
| **Total** | **25MB** | **33MB** | **60MB** |
|
||||||
|
|
||||||
|
**Note**: These are estimates. Actual usage depends on request volume, payload sizes, and configuration.
|
||||||
|
|
||||||
|
### 10. Further Reading
|
||||||
|
|
||||||
|
- [Tokio performance guide](https://tokio.rs/tokio/topics/performance)
|
||||||
|
- [Rust performance book](https://nnethercote.github.io/perf-book/)
|
||||||
|
- [Linux memory management](https://www.kernel.org/doc/html/latest/admin-guide/mm/)
|
||||||
|
- [SQLite performance tips](https://www.sqlite.org/faq.html#q19)
|
||||||
100
README.md
Normal file
100
README.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# LLM Proxy Gateway
|
||||||
|
|
||||||
|
A unified, high-performance LLM proxy gateway built in Rust. It provides a single OpenAI-compatible API to access multiple providers (OpenAI, Gemini, DeepSeek, Grok) with built-in token tracking, real-time cost calculation, and a management dashboard.
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
- **Unified API:** Fully OpenAI-compatible `/v1/chat/completions` endpoint.
|
||||||
|
- **Multi-Provider Support:**
|
||||||
|
* **OpenAI:** Standard models (GPT-4o, GPT-3.5, etc.) and reasoning models (o1, o3).
|
||||||
|
* **Google Gemini:** Support for the latest Gemini 2.0 models.
|
||||||
|
* **DeepSeek:** High-performance, low-cost integration.
|
||||||
|
* **xAI Grok:** Integration for Grok-series models.
|
||||||
|
* **Ollama:** Support for local LLMs running on your machine or another host.
|
||||||
|
- **Observability & Tracking:**
|
||||||
|
* **Real-time Costing:** Fetches live pricing and context specs from `models.dev` on startup.
|
||||||
|
* **Token Counting:** Precise estimation using `tiktoken-rs`.
|
||||||
|
* **Database Logging:** Every request is logged to SQLite for historical analysis.
|
||||||
|
* **Streaming Support:** Full SSE (Server-Sent Events) support with aggregated token tracking.
|
||||||
|
- **Multimodal (Vision):** Support for image processing (Base64 and remote URLs) across compatible providers.
|
||||||
|
- **Reliability:**
|
||||||
|
* **Circuit Breaking:** Automatically protects your system when providers are down.
|
||||||
|
* **Rate Limiting:** Granular per-client and global rate limits.
|
||||||
|
- **Management Dashboard:** A modern, real-time web interface to monitor usage, costs, and system health.
|
||||||
|
|
||||||
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
|
- **Runtime:** Rust (2024 Edition) with [Tokio](https://tokio.rs/).
|
||||||
|
- **Web Framework:** [Axum](https://github.com/tokio-rs/axum).
|
||||||
|
- **Database:** [SQLx](https://github.com/launchbadge/sqlx) with SQLite.
|
||||||
|
- **Frontend:** Vanilla JS/CSS (no heavyweight framework required).
|
||||||
|
|
||||||
|
## 🚦 Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Rust (1.80+)
|
||||||
|
- SQLite3
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/llm-proxy.git
|
||||||
|
cd llm-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Set up your environment:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env and add your API keys
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Configure providers and server:
|
||||||
|
Edit `config.toml` to customize models, pricing fallbacks, and port settings.
|
||||||
|
|
||||||
|
**Ollama Example (config.toml):**
|
||||||
|
```toml
|
||||||
|
[providers.ollama]
|
||||||
|
enabled = true
|
||||||
|
base_url = "http://192.168.1.50:11434/v1"
|
||||||
|
models = ["llama3", "mistral"]
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run the proxy:
|
||||||
|
```bash
|
||||||
|
cargo run --release
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will start at `http://localhost:3000` (by default).
|
||||||
|
|
||||||
|
## 📊 Management Dashboard
|
||||||
|
|
||||||
|
Access the built-in dashboard at `http://localhost:3000` to see:
|
||||||
|
- **Usage Summary:** Total requests, tokens, and USD spent.
|
||||||
|
- **Trend Charts:** 24-hour request and cost distributions.
|
||||||
|
- **Live Logs:** Real-time stream of incoming LLM requests via WebSockets.
|
||||||
|
- **Provider Health:** Monitor which providers are online or degraded.
|
||||||
|
|
||||||
|
## 🔌 API Usage
|
||||||
|
|
||||||
|
The proxy is designed to be a drop-in replacement for OpenAI. Simply change your base URL:
|
||||||
|
|
||||||
|
**Example Request (Python):**
|
||||||
|
```python
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
client = OpenAI(
|
||||||
|
base_url="http://localhost:3000/v1",
|
||||||
|
api_key="your-proxy-client-id" # Hashed sk- keys are managed in the dashboard
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model="gpt-4o",
|
||||||
|
messages=[{"role": "user", "content": "Hello from the proxy!"}]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚖️ License
|
||||||
|
|
||||||
|
MIT OR Apache-2.0
|
||||||
610
deploy.sh
Executable file
610
deploy.sh
Executable file
@@ -0,0 +1,610 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# LLM Proxy Gateway Deployment Script
|
||||||
|
# This script automates the deployment of the LLM Proxy Gateway on a Linux server
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
set -u # Exit on undefined variable
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
APP_NAME="llm-proxy"
|
||||||
|
APP_USER="llmproxy"
|
||||||
|
APP_GROUP="llmproxy"
|
||||||
|
INSTALL_DIR="/opt/$APP_NAME"
|
||||||
|
CONFIG_DIR="/etc/$APP_NAME"
|
||||||
|
DATA_DIR="/var/lib/$APP_NAME"
|
||||||
|
LOG_DIR="/var/log/$APP_NAME"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/$APP_NAME.service"
|
||||||
|
ENV_FILE="$CONFIG_DIR/.env"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Logging functions
|
||||||
|
log_info() {
|
||||||
|
echo -e "${GREEN}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
check_root() {
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
log_error "This script must be run as root"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
install_dependencies() {
|
||||||
|
log_info "Installing system dependencies..."
|
||||||
|
|
||||||
|
# Detect package manager
|
||||||
|
if command -v apt-get &> /dev/null; then
|
||||||
|
# Debian/Ubuntu
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
sqlite3 \
|
||||||
|
curl \
|
||||||
|
git
|
||||||
|
elif command -v yum &> /dev/null; then
|
||||||
|
# RHEL/CentOS
|
||||||
|
yum groupinstall -y "Development Tools"
|
||||||
|
yum install -y \
|
||||||
|
openssl-devel \
|
||||||
|
sqlite \
|
||||||
|
curl \
|
||||||
|
git
|
||||||
|
elif command -v dnf &> /dev/null; then
|
||||||
|
# Fedora
|
||||||
|
dnf groupinstall -y "Development Tools"
|
||||||
|
dnf install -y \
|
||||||
|
openssl-devel \
|
||||||
|
sqlite \
|
||||||
|
curl \
|
||||||
|
git
|
||||||
|
elif command -v pacman &> /dev/null; then
|
||||||
|
# Arch Linux
|
||||||
|
pacman -Syu --noconfirm \
|
||||||
|
base-devel \
|
||||||
|
openssl \
|
||||||
|
sqlite \
|
||||||
|
curl \
|
||||||
|
git
|
||||||
|
else
|
||||||
|
log_warn "Could not detect package manager. Please install dependencies manually."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install Rust if not present
|
||||||
|
install_rust() {
|
||||||
|
log_info "Checking for Rust installation..."
|
||||||
|
|
||||||
|
if ! command -v rustc &> /dev/null; then
|
||||||
|
log_info "Installing Rust..."
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
source "$HOME/.cargo/env"
|
||||||
|
else
|
||||||
|
log_info "Rust is already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create system user and directories
|
||||||
|
setup_directories() {
|
||||||
|
log_info "Creating system user and directories..."
|
||||||
|
|
||||||
|
# Create user and group if they don't exist
|
||||||
|
if ! id "$APP_USER" &>/dev/null; then
|
||||||
|
useradd -r -s /usr/sbin/nologin -M "$APP_USER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
chown -R "$APP_USER:$APP_GROUP" "$INSTALL_DIR"
|
||||||
|
chown -R "$APP_USER:$APP_GROUP" "$CONFIG_DIR"
|
||||||
|
chown -R "$APP_USER:$APP_GROUP" "$DATA_DIR"
|
||||||
|
chown -R "$APP_USER:$APP_GROUP" "$LOG_DIR"
|
||||||
|
|
||||||
|
chmod 750 "$INSTALL_DIR"
|
||||||
|
chmod 750 "$CONFIG_DIR"
|
||||||
|
chmod 750 "$DATA_DIR"
|
||||||
|
chmod 750 "$LOG_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
build_application() {
|
||||||
|
log_info "Building the application..."
|
||||||
|
|
||||||
|
# Clone or update repository
|
||||||
|
if [[ ! -d "$INSTALL_DIR/.git" ]]; then
|
||||||
|
log_info "Cloning repository..."
|
||||||
|
git clone https://github.com/yourusername/llm-proxy.git "$INSTALL_DIR"
|
||||||
|
else
|
||||||
|
log_info "Updating repository..."
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
git pull
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build in release mode
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
log_info "Building release binary..."
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Verify build
|
||||||
|
if [[ -f "target/release/$APP_NAME" ]]; then
|
||||||
|
log_info "Build successful"
|
||||||
|
else
|
||||||
|
log_error "Build failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create configuration files
|
||||||
|
create_configuration() {
|
||||||
|
log_info "Creating configuration files..."
|
||||||
|
|
||||||
|
# Create .env file with API keys
|
||||||
|
cat > "$ENV_FILE" << EOF
|
||||||
|
# LLM Proxy Gateway Environment Variables
|
||||||
|
# Add your API keys here
|
||||||
|
|
||||||
|
# OpenAI API Key
|
||||||
|
# OPENAI_API_KEY=sk-your-key-here
|
||||||
|
|
||||||
|
# Google Gemini API Key
|
||||||
|
# GEMINI_API_KEY=AIza-your-key-here
|
||||||
|
|
||||||
|
# DeepSeek API Key
|
||||||
|
# DEEPSEEK_API_KEY=sk-your-key-here
|
||||||
|
|
||||||
|
# xAI Grok API Key
|
||||||
|
# GROK_API_KEY=gk-your-key-here
|
||||||
|
|
||||||
|
# Authentication tokens (comma-separated)
|
||||||
|
# LLM_PROXY__SERVER__AUTH_TOKENS=token1,token2,token3
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Create config.toml
|
||||||
|
cat > "$CONFIG_DIR/config.toml" << EOF
|
||||||
|
# LLM Proxy Gateway Configuration
|
||||||
|
|
||||||
|
[server]
|
||||||
|
port = 8080
|
||||||
|
host = "0.0.0.0"
|
||||||
|
# auth_tokens = ["token1", "token2", "token3"] # Uncomment to enable authentication
|
||||||
|
|
||||||
|
[database]
|
||||||
|
path = "$DATA_DIR/llm_proxy.db"
|
||||||
|
max_connections = 5
|
||||||
|
|
||||||
|
[providers.openai]
|
||||||
|
enabled = true
|
||||||
|
api_key_env = "OPENAI_API_KEY"
|
||||||
|
base_url = "https://api.openai.com/v1"
|
||||||
|
default_model = "gpt-4o"
|
||||||
|
|
||||||
|
[providers.gemini]
|
||||||
|
enabled = true
|
||||||
|
api_key_env = "GEMINI_API_KEY"
|
||||||
|
base_url = "https://generativelanguage.googleapis.com/v1"
|
||||||
|
default_model = "gemini-2.0-flash"
|
||||||
|
|
||||||
|
[providers.deepseek]
|
||||||
|
enabled = true
|
||||||
|
api_key_env = "DEEPSEEK_API_KEY"
|
||||||
|
base_url = "https://api.deepseek.com"
|
||||||
|
default_model = "deepseek-reasoner"
|
||||||
|
|
||||||
|
[providers.grok]
|
||||||
|
enabled = false # Disabled by default until API is researched
|
||||||
|
api_key_env = "GROK_API_KEY"
|
||||||
|
base_url = "https://api.x.ai/v1"
|
||||||
|
default_model = "grok-beta"
|
||||||
|
|
||||||
|
[model_mapping]
|
||||||
|
"gpt-*" = "openai"
|
||||||
|
"gemini-*" = "gemini"
|
||||||
|
"deepseek-*" = "deepseek"
|
||||||
|
"grok-*" = "grok"
|
||||||
|
|
||||||
|
[pricing]
|
||||||
|
openai = { input = 0.01, output = 0.03 }
|
||||||
|
gemini = { input = 0.0005, output = 0.0015 }
|
||||||
|
deepseek = { input = 0.00014, output = 0.00028 }
|
||||||
|
grok = { input = 0.001, output = 0.003 }
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
chown "$APP_USER:$APP_GROUP" "$ENV_FILE"
|
||||||
|
chown "$APP_USER:$APP_GROUP" "$CONFIG_DIR/config.toml"
|
||||||
|
chmod 640 "$ENV_FILE"
|
||||||
|
chmod 640 "$CONFIG_DIR/config.toml"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create systemd service
|
||||||
|
create_systemd_service() {
|
||||||
|
log_info "Creating systemd service..."
|
||||||
|
|
||||||
|
cat > "$SERVICE_FILE" << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=LLM Proxy Gateway
|
||||||
|
Documentation=https://github.com/yourusername/llm-proxy
|
||||||
|
After=network.target
|
||||||
|
Wants=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$APP_USER
|
||||||
|
Group=$APP_GROUP
|
||||||
|
WorkingDirectory=$INSTALL_DIR
|
||||||
|
EnvironmentFile=$ENV_FILE
|
||||||
|
Environment="RUST_LOG=info"
|
||||||
|
Environment="LLM_PROXY__CONFIG_PATH=$CONFIG_DIR/config.toml"
|
||||||
|
ExecStart=$INSTALL_DIR/target/release/$APP_NAME
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=$DATA_DIR $LOG_DIR
|
||||||
|
|
||||||
|
# Resource limits (adjust based on your server)
|
||||||
|
MemoryMax=400M
|
||||||
|
MemorySwapMax=100M
|
||||||
|
CPUQuota=50%
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=$APP_NAME
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Reload systemd
|
||||||
|
systemctl daemon-reload
|
||||||
|
}
|
||||||
|
|
||||||
|
# Setup nginx reverse proxy (optional)
|
||||||
|
setup_nginx_proxy() {
|
||||||
|
if ! command -v nginx &> /dev/null; then
|
||||||
|
log_warn "nginx not installed. Skipping reverse proxy setup."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Setting up nginx reverse proxy..."
|
||||||
|
|
||||||
|
cat > "/etc/nginx/sites-available/$APP_NAME" << EOF
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com; # Change to your domain
|
||||||
|
|
||||||
|
# Redirect to HTTPS (recommended)
|
||||||
|
return 301 https://\$server_name\$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name your-domain.com; # Change to your domain
|
||||||
|
|
||||||
|
# SSL certificates (adjust paths)
|
||||||
|
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||||
|
|
||||||
|
# SSL configuration
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
|
||||||
|
# Proxy to LLM Proxy Gateway
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
|
||||||
|
# Timeouts
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://127.0.0.1:8080/health;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dashboard
|
||||||
|
location /dashboard {
|
||||||
|
proxy_pass http://127.0.0.1:8080/dashboard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Enable site
|
||||||
|
ln -sf "/etc/nginx/sites-available/$APP_NAME" "/etc/nginx/sites-enabled/"
|
||||||
|
|
||||||
|
# Test nginx configuration
|
||||||
|
nginx -t
|
||||||
|
|
||||||
|
log_info "nginx configuration created. Please update the domain and SSL certificate paths."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Setup firewall
|
||||||
|
setup_firewall() {
|
||||||
|
log_info "Configuring firewall..."
|
||||||
|
|
||||||
|
# Check for ufw (Ubuntu)
|
||||||
|
if command -v ufw &> /dev/null; then
|
||||||
|
ufw allow 22/tcp # SSH
|
||||||
|
ufw allow 80/tcp # HTTP
|
||||||
|
ufw allow 443/tcp # HTTPS
|
||||||
|
ufw --force enable
|
||||||
|
log_info "UFW firewall configured"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for firewalld (RHEL/CentOS)
|
||||||
|
if command -v firewall-cmd &> /dev/null; then
|
||||||
|
firewall-cmd --permanent --add-service=ssh
|
||||||
|
firewall-cmd --permanent --add-service=http
|
||||||
|
firewall-cmd --permanent --add-service=https
|
||||||
|
firewall-cmd --reload
|
||||||
|
log_info "Firewalld configured"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
initialize_database() {
|
||||||
|
log_info "Initializing database..."
|
||||||
|
|
||||||
|
# Run the application once to create database
|
||||||
|
sudo -u "$APP_USER" "$INSTALL_DIR/target/release/$APP_NAME" --help &> /dev/null || true
|
||||||
|
|
||||||
|
log_info "Database initialized at $DATA_DIR/llm_proxy.db"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start and enable service
|
||||||
|
start_service() {
|
||||||
|
log_info "Starting $APP_NAME service..."
|
||||||
|
|
||||||
|
systemctl enable "$APP_NAME"
|
||||||
|
systemctl start "$APP_NAME"
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
sleep 2
|
||||||
|
systemctl status "$APP_NAME" --no-pager
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
verify_installation() {
|
||||||
|
log_info "Verifying installation..."
|
||||||
|
|
||||||
|
# Check if service is running
|
||||||
|
if systemctl is-active --quiet "$APP_NAME"; then
|
||||||
|
log_info "Service is running"
|
||||||
|
else
|
||||||
|
log_error "Service is not running"
|
||||||
|
journalctl -u "$APP_NAME" -n 20 --no-pager
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test health endpoint
|
||||||
|
if curl -s http://localhost:8080/health | grep -q "OK"; then
|
||||||
|
log_info "Health check passed"
|
||||||
|
else
|
||||||
|
log_error "Health check failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test dashboard
|
||||||
|
if curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/dashboard | grep -q "200"; then
|
||||||
|
log_info "Dashboard is accessible"
|
||||||
|
else
|
||||||
|
log_warn "Dashboard may not be accessible (this is normal if not configured)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Installation verified successfully!"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Print next steps
|
||||||
|
print_next_steps() {
|
||||||
|
cat << EOF
|
||||||
|
|
||||||
|
${GREEN}=== LLM Proxy Gateway Installation Complete ===${NC}
|
||||||
|
|
||||||
|
${YELLOW}Next steps:${NC}
|
||||||
|
|
||||||
|
1. ${GREEN}Configure API keys${NC}
|
||||||
|
Edit: $ENV_FILE
|
||||||
|
Add your API keys for the providers you want to use
|
||||||
|
|
||||||
|
2. ${GREEN}Configure authentication${NC}
|
||||||
|
Edit: $CONFIG_DIR/config.toml
|
||||||
|
Uncomment and set auth_tokens for client authentication
|
||||||
|
|
||||||
|
3. ${GREEN}Configure nginx${NC}
|
||||||
|
Edit: /etc/nginx/sites-available/$APP_NAME
|
||||||
|
Update domain name and SSL certificate paths
|
||||||
|
|
||||||
|
4. ${GREEN}Test the API${NC}
|
||||||
|
curl -X POST http://localhost:8080/v1/chat/completions \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "Authorization: Bearer your-token" \\
|
||||||
|
-d '{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"messages": [{"role": "user", "content": "Hello!"}]
|
||||||
|
}'
|
||||||
|
|
||||||
|
5. ${GREEN}Access the dashboard${NC}
|
||||||
|
Open: http://your-server-ip:8080/dashboard
|
||||||
|
Or: https://your-domain.com/dashboard (if nginx configured)
|
||||||
|
|
||||||
|
${YELLOW}Useful commands:${NC}
|
||||||
|
systemctl status $APP_NAME # Check service status
|
||||||
|
journalctl -u $APP_NAME -f # View logs
|
||||||
|
systemctl restart $APP_NAME # Restart service
|
||||||
|
|
||||||
|
${YELLOW}Configuration files:${NC}
|
||||||
|
Service: $SERVICE_FILE
|
||||||
|
Config: $CONFIG_DIR/config.toml
|
||||||
|
Environment: $ENV_FILE
|
||||||
|
Database: $DATA_DIR/llm_proxy.db
|
||||||
|
Logs: $LOG_DIR/
|
||||||
|
|
||||||
|
${GREEN}For more information, see:${NC}
|
||||||
|
https://github.com/yourusername/llm-proxy
|
||||||
|
$INSTALL_DIR/README.md
|
||||||
|
$INSTALL_DIR/deployment.md
|
||||||
|
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main deployment function
|
||||||
|
deploy() {
|
||||||
|
log_info "Starting LLM Proxy Gateway deployment..."
|
||||||
|
|
||||||
|
check_root
|
||||||
|
install_dependencies
|
||||||
|
install_rust
|
||||||
|
setup_directories
|
||||||
|
build_application
|
||||||
|
create_configuration
|
||||||
|
create_systemd_service
|
||||||
|
initialize_database
|
||||||
|
start_service
|
||||||
|
verify_installation
|
||||||
|
print_next_steps
|
||||||
|
|
||||||
|
# Optional steps (uncomment if needed)
|
||||||
|
# setup_nginx_proxy
|
||||||
|
# setup_firewall
|
||||||
|
|
||||||
|
log_info "Deployment completed successfully!"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update function
|
||||||
|
update() {
|
||||||
|
log_info "Updating LLM Proxy Gateway..."
|
||||||
|
|
||||||
|
check_root
|
||||||
|
|
||||||
|
# Stop service
|
||||||
|
systemctl stop "$APP_NAME"
|
||||||
|
|
||||||
|
# Update from git
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# Rebuild
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Restart service
|
||||||
|
systemctl start "$APP_NAME"
|
||||||
|
|
||||||
|
log_info "Update completed successfully!"
|
||||||
|
systemctl status "$APP_NAME" --no-pager
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uninstall function
|
||||||
|
uninstall() {
|
||||||
|
log_info "Uninstalling LLM Proxy Gateway..."
|
||||||
|
|
||||||
|
check_root
|
||||||
|
|
||||||
|
# Stop and disable service
|
||||||
|
systemctl stop "$APP_NAME" 2>/dev/null || true
|
||||||
|
systemctl disable "$APP_NAME" 2>/dev/null || true
|
||||||
|
rm -f "$SERVICE_FILE"
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
# Remove application files
|
||||||
|
rm -rf "$INSTALL_DIR"
|
||||||
|
rm -rf "$CONFIG_DIR"
|
||||||
|
|
||||||
|
# Keep data and logs (comment out to remove)
|
||||||
|
log_warn "Data directory $DATA_DIR and logs $LOG_DIR have been preserved"
|
||||||
|
log_warn "Remove manually if desired:"
|
||||||
|
log_warn " rm -rf $DATA_DIR $LOG_DIR"
|
||||||
|
|
||||||
|
# Remove user (optional)
|
||||||
|
read -p "Remove user $APP_USER? [y/N]: " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
userdel "$APP_USER" 2>/dev/null || true
|
||||||
|
groupdel "$APP_GROUP" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Uninstallation completed!"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show usage
|
||||||
|
usage() {
|
||||||
|
cat << EOF
|
||||||
|
LLM Proxy Gateway Deployment Script
|
||||||
|
|
||||||
|
Usage: $0 [command]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
deploy - Install and configure LLM Proxy Gateway
|
||||||
|
update - Update existing installation
|
||||||
|
uninstall - Remove LLM Proxy Gateway
|
||||||
|
help - Show this help message
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$0 deploy # Full installation
|
||||||
|
$0 update # Update to latest version
|
||||||
|
$0 uninstall # Remove installation
|
||||||
|
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse command line arguments
|
||||||
|
case "${1:-}" in
|
||||||
|
deploy)
|
||||||
|
deploy
|
||||||
|
;;
|
||||||
|
update)
|
||||||
|
update
|
||||||
|
;;
|
||||||
|
uninstall)
|
||||||
|
uninstall
|
||||||
|
;;
|
||||||
|
help|--help|-h)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
294
deployment.md
Normal file
294
deployment.md
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
# LLM Proxy Gateway - Deployment Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
A unified LLM proxy gateway supporting OpenAI, Google Gemini, DeepSeek, and xAI Grok with token tracking, cost calculation, and admin dashboard.
|
||||||
|
|
||||||
|
## System Requirements
|
||||||
|
- **CPU**: 2 cores minimum
|
||||||
|
- **RAM**: 512MB minimum (1GB recommended)
|
||||||
|
- **Storage**: 10GB minimum
|
||||||
|
- **OS**: Linux (tested on Arch Linux, Ubuntu, Debian)
|
||||||
|
- **Runtime**: Rust 1.70+ with Cargo
|
||||||
|
|
||||||
|
## Deployment Options
|
||||||
|
|
||||||
|
### Option 1: Docker (Recommended)
|
||||||
|
```dockerfile
|
||||||
|
FROM rust:1.70-alpine as builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
FROM alpine:latest
|
||||||
|
RUN apk add --no-cache libgcc
|
||||||
|
COPY --from=builder /app/target/release/llm-proxy /usr/local/bin/
|
||||||
|
COPY --from=builder /app/static /app/static
|
||||||
|
WORKDIR /app
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["llm-proxy"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Systemd Service (Bare Metal/LXC)
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/llm-proxy.service
|
||||||
|
[Unit]
|
||||||
|
Description=LLM Proxy Gateway
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=llmproxy
|
||||||
|
Group=llmproxy
|
||||||
|
WorkingDirectory=/opt/llm-proxy
|
||||||
|
ExecStart=/opt/llm-proxy/llm-proxy
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
Environment="RUST_LOG=info"
|
||||||
|
Environment="LLM_PROXY__SERVER__PORT=8080"
|
||||||
|
Environment="LLM_PROXY__SERVER__AUTH_TOKENS=sk-test-123,sk-test-456"
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: LXC Container (Proxmox)
|
||||||
|
1. Create Alpine Linux LXC container
|
||||||
|
2. Install Rust: `apk add rust cargo`
|
||||||
|
3. Copy application files
|
||||||
|
4. Build: `cargo build --release`
|
||||||
|
5. Run: `./target/release/llm-proxy`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
```bash
|
||||||
|
# Required API Keys
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
GEMINI_API_KEY=AIza...
|
||||||
|
DEEPSEEK_API_KEY=sk-...
|
||||||
|
GROK_API_KEY=gk-... # Optional
|
||||||
|
|
||||||
|
# Server Configuration (with LLM_PROXY__ prefix)
|
||||||
|
LLM_PROXY__SERVER__PORT=8080
|
||||||
|
LLM_PROXY__SERVER__HOST=0.0.0.0
|
||||||
|
LLM_PROXY__SERVER__AUTH_TOKENS=sk-test-123,sk-test-456
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
LLM_PROXY__DATABASE__PATH=./data/llm_proxy.db
|
||||||
|
LLM_PROXY__DATABASE__MAX_CONNECTIONS=10
|
||||||
|
|
||||||
|
# Provider Configuration
|
||||||
|
LLM_PROXY__PROVIDERS__OPENAI__ENABLED=true
|
||||||
|
LLM_PROXY__PROVIDERS__GEMINI__ENABLED=true
|
||||||
|
LLM_PROXY__PROVIDERS__DEEPSEEK__ENABLED=true
|
||||||
|
LLM_PROXY__PROVIDERS__GROK__ENABLED=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration File (config.toml)
|
||||||
|
Create `config.toml` in the application directory:
|
||||||
|
```toml
|
||||||
|
[server]
|
||||||
|
port = 8080
|
||||||
|
host = "0.0.0.0"
|
||||||
|
auth_tokens = ["sk-test-123", "sk-test-456"]
|
||||||
|
|
||||||
|
[database]
|
||||||
|
path = "./data/llm_proxy.db"
|
||||||
|
max_connections = 10
|
||||||
|
|
||||||
|
[providers.openai]
|
||||||
|
enabled = true
|
||||||
|
base_url = "https://api.openai.com/v1"
|
||||||
|
default_model = "gpt-4o"
|
||||||
|
|
||||||
|
[providers.gemini]
|
||||||
|
enabled = true
|
||||||
|
base_url = "https://generativelanguage.googleapis.com/v1"
|
||||||
|
default_model = "gemini-2.0-flash"
|
||||||
|
|
||||||
|
[providers.deepseek]
|
||||||
|
enabled = true
|
||||||
|
base_url = "https://api.deepseek.com"
|
||||||
|
default_model = "deepseek-reasoner"
|
||||||
|
|
||||||
|
[providers.grok]
|
||||||
|
enabled = false
|
||||||
|
base_url = "https://api.x.ai/v1"
|
||||||
|
default_model = "grok-beta"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nginx Reverse Proxy Configuration
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name llm-proxy.yourdomain.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
|
# WebSocket support
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SSL configuration (recommended)
|
||||||
|
listen 443 ssl http2;
|
||||||
|
ssl_certificate /etc/letsencrypt/live/llm-proxy.yourdomain.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/llm-proxy.yourdomain.com/privkey.pem;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### 1. Authentication
|
||||||
|
- Use strong Bearer tokens
|
||||||
|
- Rotate tokens regularly
|
||||||
|
- Consider implementing JWT for production
|
||||||
|
|
||||||
|
### 2. Rate Limiting
|
||||||
|
- Implement per-client rate limiting
|
||||||
|
- Consider using `governor` crate for advanced rate limiting
|
||||||
|
|
||||||
|
### 3. Network Security
|
||||||
|
- Run behind reverse proxy (nginx)
|
||||||
|
- Enable HTTPS
|
||||||
|
- Restrict access by IP if needed
|
||||||
|
- Use firewall rules
|
||||||
|
|
||||||
|
### 4. Data Security
|
||||||
|
- Database encryption (SQLCipher for SQLite)
|
||||||
|
- Secure API key storage
|
||||||
|
- Regular backups
|
||||||
|
|
||||||
|
## Monitoring & Maintenance
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
- Application logs: `RUST_LOG=info` (or `debug` for troubleshooting)
|
||||||
|
- Access logs via nginx
|
||||||
|
- Database logs for audit trail
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
```bash
|
||||||
|
# Health endpoint
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
|
||||||
|
# Database check
|
||||||
|
sqlite3 ./data/llm_proxy.db "SELECT COUNT(*) FROM llm_requests;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup Strategy
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# backup.sh
|
||||||
|
BACKUP_DIR="/backups/llm-proxy"
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
|
||||||
|
# Backup database
|
||||||
|
sqlite3 ./data/llm_proxy.db ".backup $BACKUP_DIR/llm_proxy_$DATE.db"
|
||||||
|
|
||||||
|
# Backup configuration
|
||||||
|
cp config.toml $BACKUP_DIR/config_$DATE.toml
|
||||||
|
|
||||||
|
# Rotate old backups (keep 30 days)
|
||||||
|
find $BACKUP_DIR -name "*.db" -mtime +30 -delete
|
||||||
|
find $BACKUP_DIR -name "*.toml" -mtime +30 -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Tuning
|
||||||
|
|
||||||
|
### Database Optimization
|
||||||
|
```sql
|
||||||
|
-- Run these SQL commands periodically
|
||||||
|
VACUUM;
|
||||||
|
ANALYZE;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Memory Management
|
||||||
|
- Monitor memory usage with `htop` or `ps aux`
|
||||||
|
- Adjust `max_connections` based on load
|
||||||
|
- Consider connection pooling for high traffic
|
||||||
|
|
||||||
|
### Scaling
|
||||||
|
1. **Vertical Scaling**: Increase container resources
|
||||||
|
2. **Horizontal Scaling**: Deploy multiple instances behind load balancer
|
||||||
|
3. **Database**: Migrate to PostgreSQL for high-volume usage
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Port already in use**
|
||||||
|
```bash
|
||||||
|
netstat -tulpn | grep :8080
|
||||||
|
kill <PID> # or change port in config
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Database permissions**
|
||||||
|
```bash
|
||||||
|
chown -R llmproxy:llmproxy /opt/llm-proxy/data
|
||||||
|
chmod 600 /opt/llm-proxy/data/llm_proxy.db
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **API key errors**
|
||||||
|
- Verify environment variables are set
|
||||||
|
- Check provider status (dashboard)
|
||||||
|
- Test connectivity: `curl https://api.openai.com/v1/models`
|
||||||
|
|
||||||
|
4. **High memory usage**
|
||||||
|
- Check for memory leaks
|
||||||
|
- Reduce `max_connections`
|
||||||
|
- Implement connection timeouts
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
```bash
|
||||||
|
# Run with debug logging
|
||||||
|
RUST_LOG=debug ./llm-proxy
|
||||||
|
|
||||||
|
# Check system logs
|
||||||
|
journalctl -u llm-proxy -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
### Open-WebUI Compatibility
|
||||||
|
The proxy provides OpenAI-compatible API, so configure Open-WebUI:
|
||||||
|
```
|
||||||
|
API Base URL: http://your-proxy-address:8080
|
||||||
|
API Key: sk-test-123 (or your configured token)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Clients
|
||||||
|
```python
|
||||||
|
import openai
|
||||||
|
|
||||||
|
client = openai.OpenAI(
|
||||||
|
base_url="http://localhost:8080/v1",
|
||||||
|
api_key="sk-test-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model="gpt-4",
|
||||||
|
messages=[{"role": "user", "content": "Hello"}]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updates & Upgrades
|
||||||
|
|
||||||
|
1. **Backup** current configuration and database
|
||||||
|
2. **Stop** the service: `systemctl stop llm-proxy`
|
||||||
|
3. **Update** code: `git pull` or copy new binaries
|
||||||
|
4. **Migrate** database if needed (check migrations/)
|
||||||
|
5. **Restart**: `systemctl start llm-proxy`
|
||||||
|
6. **Verify**: Check logs and test endpoints
|
||||||
|
|
||||||
|
## Support
|
||||||
|
- Check logs in `/var/log/llm-proxy/`
|
||||||
|
- Monitor dashboard at `http://your-server:8080`
|
||||||
|
- Review database metrics in dashboard
|
||||||
|
- Enable debug logging for troubleshooting
|
||||||
46
src/auth/mod.rs
Normal file
46
src/auth/mod.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
use axum::{extract::FromRequestParts, http::request::Parts};
|
||||||
|
use axum_extra::headers::Authorization;
|
||||||
|
use axum_extra::TypedHeader;
|
||||||
|
use headers::authorization::Bearer;
|
||||||
|
|
||||||
|
use crate::errors::AppError;
|
||||||
|
|
||||||
|
pub struct AuthenticatedClient {
|
||||||
|
pub token: String,
|
||||||
|
pub client_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for AuthenticatedClient
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = AppError;
|
||||||
|
|
||||||
|
fn from_request_parts(parts: &mut Parts, state: &S) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send {
|
||||||
|
async move {
|
||||||
|
// Extract bearer token from Authorization header
|
||||||
|
let TypedHeader(Authorization(bearer)) =
|
||||||
|
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError::AuthError("Missing or invalid bearer token".to_string()))?;
|
||||||
|
|
||||||
|
let token = bearer.token().to_string();
|
||||||
|
|
||||||
|
// In a real implementation, we would:
|
||||||
|
// 1. Validate token against database or config
|
||||||
|
// 2. Look up client_id associated with token
|
||||||
|
// 3. Check token permissions/rate limits
|
||||||
|
|
||||||
|
// For now, use token hash as client_id
|
||||||
|
let client_id = format!("client_{}", &token[..8]);
|
||||||
|
|
||||||
|
Ok(AuthenticatedClient { token, client_id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_token(token: &str, valid_tokens: &[String]) -> bool {
|
||||||
|
// Simple validation against list of tokens
|
||||||
|
// In production, use proper token validation (JWT, database lookup, etc.)
|
||||||
|
valid_tokens.contains(&token.to_string())
|
||||||
|
}
|
||||||
310
src/client/mod.rs
Normal file
310
src/client/mod.rs
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
//! Client management for LLM proxy
|
||||||
|
//!
|
||||||
|
//! This module handles:
|
||||||
|
//! 1. Client registration and management
|
||||||
|
//! 2. Client usage tracking
|
||||||
|
//! 3. Client rate limit configuration
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{SqlitePool, Row};
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
/// Client information
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Client {
|
||||||
|
pub id: i64,
|
||||||
|
pub client_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub rate_limit_per_minute: i64,
|
||||||
|
pub total_requests: i64,
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub total_cost: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client creation request
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CreateClientRequest {
|
||||||
|
pub client_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub rate_limit_per_minute: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client update request
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateClientRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub is_active: Option<bool>,
|
||||||
|
pub rate_limit_per_minute: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client manager for database operations
|
||||||
|
pub struct ClientManager {
|
||||||
|
db_pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientManager {
|
||||||
|
pub fn new(db_pool: SqlitePool) -> Self {
|
||||||
|
Self { db_pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new client
|
||||||
|
pub async fn create_client(&self, request: CreateClientRequest) -> Result<Client> {
|
||||||
|
let rate_limit = request.rate_limit_per_minute.unwrap_or(60);
|
||||||
|
|
||||||
|
// First insert the client
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO clients (client_id, name, description, rate_limit_per_minute)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&request.client_id)
|
||||||
|
.bind(&request.name)
|
||||||
|
.bind(&request.description)
|
||||||
|
.bind(rate_limit)
|
||||||
|
.execute(&self.db_pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Then fetch the created client
|
||||||
|
let client = self.get_client(&request.client_id).await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Failed to retrieve created client"))?;
|
||||||
|
|
||||||
|
info!("Created client: {} ({})", client.name, client.client_id);
|
||||||
|
Ok(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a client by ID
|
||||||
|
pub async fn get_client(&self, client_id: &str) -> Result<Option<Client>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id, client_id, name, description,
|
||||||
|
created_at, updated_at, is_active,
|
||||||
|
rate_limit_per_minute, total_requests, total_tokens, total_cost
|
||||||
|
FROM clients
|
||||||
|
WHERE client_id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(client_id)
|
||||||
|
.fetch_optional(&self.db_pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(row) = row {
|
||||||
|
let client = Client {
|
||||||
|
id: row.get("id"),
|
||||||
|
client_id: row.get("client_id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
description: row.get("description"),
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
updated_at: row.get("updated_at"),
|
||||||
|
is_active: row.get("is_active"),
|
||||||
|
rate_limit_per_minute: row.get("rate_limit_per_minute"),
|
||||||
|
total_requests: row.get("total_requests"),
|
||||||
|
total_tokens: row.get("total_tokens"),
|
||||||
|
total_cost: row.get("total_cost"),
|
||||||
|
};
|
||||||
|
Ok(Some(client))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a client
|
||||||
|
pub async fn update_client(&self, client_id: &str, request: UpdateClientRequest) -> Result<Option<Client>> {
|
||||||
|
// First, get the current client to check if it exists
|
||||||
|
let current_client = self.get_client(client_id).await?;
|
||||||
|
if current_client.is_none() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update query dynamically based on provided fields
|
||||||
|
let mut updates = Vec::new();
|
||||||
|
let mut query_builder = sqlx::QueryBuilder::new("UPDATE clients SET ");
|
||||||
|
let mut has_updates = false;
|
||||||
|
|
||||||
|
if let Some(name) = &request.name {
|
||||||
|
updates.push("name = ");
|
||||||
|
query_builder.push_bind(name);
|
||||||
|
has_updates = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(description) = &request.description {
|
||||||
|
if has_updates {
|
||||||
|
query_builder.push(", ");
|
||||||
|
}
|
||||||
|
updates.push("description = ");
|
||||||
|
query_builder.push_bind(description);
|
||||||
|
has_updates = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(is_active) = request.is_active {
|
||||||
|
if has_updates {
|
||||||
|
query_builder.push(", ");
|
||||||
|
}
|
||||||
|
updates.push("is_active = ");
|
||||||
|
query_builder.push_bind(is_active);
|
||||||
|
has_updates = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rate_limit) = request.rate_limit_per_minute {
|
||||||
|
if has_updates {
|
||||||
|
query_builder.push(", ");
|
||||||
|
}
|
||||||
|
updates.push("rate_limit_per_minute = ");
|
||||||
|
query_builder.push_bind(rate_limit);
|
||||||
|
has_updates = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always update the updated_at timestamp
|
||||||
|
if has_updates {
|
||||||
|
query_builder.push(", ");
|
||||||
|
}
|
||||||
|
query_builder.push("updated_at = CURRENT_TIMESTAMP");
|
||||||
|
|
||||||
|
if !has_updates {
|
||||||
|
// No updates to make
|
||||||
|
return self.get_client(client_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
query_builder.push(" WHERE client_id = ");
|
||||||
|
query_builder.push_bind(client_id);
|
||||||
|
|
||||||
|
let query = query_builder.build();
|
||||||
|
query.execute(&self.db_pool).await?;
|
||||||
|
|
||||||
|
// Fetch the updated client
|
||||||
|
let updated_client = self.get_client(client_id).await?;
|
||||||
|
|
||||||
|
if updated_client.is_some() {
|
||||||
|
info!("Updated client: {}", client_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(updated_client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all clients
|
||||||
|
pub async fn list_clients(&self, limit: Option<i64>, offset: Option<i64>) -> Result<Vec<Client>> {
|
||||||
|
let limit = limit.unwrap_or(100);
|
||||||
|
let offset = offset.unwrap_or(0);
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id, client_id, name, description,
|
||||||
|
created_at, updated_at, is_active,
|
||||||
|
rate_limit_per_minute, total_requests, total_tokens, total_cost
|
||||||
|
FROM clients
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&self.db_pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut clients = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let client = Client {
|
||||||
|
id: row.get("id"),
|
||||||
|
client_id: row.get("client_id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
description: row.get("description"),
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
updated_at: row.get("updated_at"),
|
||||||
|
is_active: row.get("is_active"),
|
||||||
|
rate_limit_per_minute: row.get("rate_limit_per_minute"),
|
||||||
|
total_requests: row.get("total_requests"),
|
||||||
|
total_tokens: row.get("total_tokens"),
|
||||||
|
total_cost: row.get("total_cost"),
|
||||||
|
};
|
||||||
|
clients.push(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(clients)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a client
|
||||||
|
pub async fn delete_client(&self, client_id: &str) -> Result<bool> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"DELETE FROM clients WHERE client_id = ?"
|
||||||
|
)
|
||||||
|
.bind(client_id)
|
||||||
|
.execute(&self.db_pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let deleted = result.rows_affected() > 0;
|
||||||
|
|
||||||
|
if deleted {
|
||||||
|
info!("Deleted client: {}", client_id);
|
||||||
|
} else {
|
||||||
|
warn!("Client not found for deletion: {}", client_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update client usage statistics after a request
|
||||||
|
pub async fn update_client_usage(
|
||||||
|
&self,
|
||||||
|
client_id: &str,
|
||||||
|
tokens: i64,
|
||||||
|
cost: f64,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE clients
|
||||||
|
SET
|
||||||
|
total_requests = total_requests + 1,
|
||||||
|
total_tokens = total_tokens + ?,
|
||||||
|
total_cost = total_cost + ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE client_id = ?
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(tokens)
|
||||||
|
.bind(cost)
|
||||||
|
.bind(client_id)
|
||||||
|
.execute(&self.db_pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get client usage statistics
|
||||||
|
pub async fn get_client_usage(&self, client_id: &str) -> Result<Option<(i64, i64, f64)>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT total_requests, total_tokens, total_cost
|
||||||
|
FROM clients
|
||||||
|
WHERE client_id = ?
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(client_id)
|
||||||
|
.fetch_optional(&self.db_pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(row) = row {
|
||||||
|
let total_requests: i64 = row.get("total_requests");
|
||||||
|
let total_tokens: i64 = row.get("total_tokens");
|
||||||
|
let total_cost: f64 = row.get("total_cost");
|
||||||
|
Ok(Some((total_requests, total_tokens, total_cost)))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a client exists and is active
|
||||||
|
pub async fn validate_client(&self, client_id: &str) -> Result<bool> {
|
||||||
|
let client = self.get_client(client_id).await?;
|
||||||
|
Ok(client.map(|c| c.is_active).unwrap_or(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
191
src/config/mod.rs
Normal file
191
src/config/mod.rs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use config::{Config, File, FileFormat};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
pub port: u16,
|
||||||
|
pub host: String,
|
||||||
|
pub auth_tokens: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DatabaseConfig {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub max_connections: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProviderConfig {
|
||||||
|
pub openai: OpenAIConfig,
|
||||||
|
pub gemini: GeminiConfig,
|
||||||
|
pub deepseek: DeepSeekConfig,
|
||||||
|
pub grok: GrokConfig,
|
||||||
|
pub ollama: OllamaConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAIConfig {
|
||||||
|
pub api_key_env: String,
|
||||||
|
pub base_url: String,
|
||||||
|
pub default_model: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GeminiConfig {
|
||||||
|
pub api_key_env: String,
|
||||||
|
pub base_url: String,
|
||||||
|
pub default_model: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DeepSeekConfig {
|
||||||
|
pub api_key_env: String,
|
||||||
|
pub base_url: String,
|
||||||
|
pub default_model: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GrokConfig {
|
||||||
|
pub api_key_env: String,
|
||||||
|
pub base_url: String,
|
||||||
|
pub default_model: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OllamaConfig {
|
||||||
|
pub base_url: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub models: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ModelMappingConfig {
|
||||||
|
pub patterns: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PricingConfig {
|
||||||
|
pub openai: Vec<ModelPricing>,
|
||||||
|
pub gemini: Vec<ModelPricing>,
|
||||||
|
pub deepseek: Vec<ModelPricing>,
|
||||||
|
pub grok: Vec<ModelPricing>,
|
||||||
|
pub ollama: Vec<ModelPricing>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ModelPricing {
|
||||||
|
pub model: String,
|
||||||
|
pub prompt_tokens_per_million: f64,
|
||||||
|
pub completion_tokens_per_million: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub server: ServerConfig,
|
||||||
|
pub database: DatabaseConfig,
|
||||||
|
pub providers: ProviderConfig,
|
||||||
|
pub model_mapping: ModelMappingConfig,
|
||||||
|
pub pricing: PricingConfig,
|
||||||
|
pub config_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
pub async fn load() -> Result<Arc<Self>> {
|
||||||
|
Self::load_from_path(None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load configuration from a specific path (for testing)
|
||||||
|
pub async fn load_from_path(config_path: Option<PathBuf>) -> Result<Arc<Self>> {
|
||||||
|
// Load configuration from multiple sources
|
||||||
|
let mut config_builder = Config::builder();
|
||||||
|
|
||||||
|
// Default configuration
|
||||||
|
config_builder = config_builder
|
||||||
|
.set_default("server.port", 8080)?
|
||||||
|
.set_default("server.host", "0.0.0.0")?
|
||||||
|
.set_default("server.auth_tokens", Vec::<String>::new())?
|
||||||
|
.set_default("database.path", "./data/llm_proxy.db")?
|
||||||
|
.set_default("database.max_connections", 10)?
|
||||||
|
.set_default("providers.openai.api_key_env", "OPENAI_API_KEY")?
|
||||||
|
.set_default("providers.openai.base_url", "https://api.openai.com/v1")?
|
||||||
|
.set_default("providers.openai.default_model", "gpt-4o")?
|
||||||
|
.set_default("providers.openai.enabled", true)?
|
||||||
|
.set_default("providers.gemini.api_key_env", "GEMINI_API_KEY")?
|
||||||
|
.set_default("providers.gemini.base_url", "https://generativelanguage.googleapis.com/v1")?
|
||||||
|
.set_default("providers.gemini.default_model", "gemini-2.0-flash")?
|
||||||
|
.set_default("providers.gemini.enabled", true)?
|
||||||
|
.set_default("providers.deepseek.api_key_env", "DEEPSEEK_API_KEY")?
|
||||||
|
.set_default("providers.deepseek.base_url", "https://api.deepseek.com")?
|
||||||
|
.set_default("providers.deepseek.default_model", "deepseek-reasoner")?
|
||||||
|
.set_default("providers.deepseek.enabled", true)?
|
||||||
|
.set_default("providers.grok.api_key_env", "GROK_API_KEY")?
|
||||||
|
.set_default("providers.grok.base_url", "https://api.x.ai/v1")?
|
||||||
|
.set_default("providers.grok.default_model", "grok-beta")?
|
||||||
|
.set_default("providers.grok.enabled", false)?
|
||||||
|
.set_default("providers.ollama.base_url", "http://localhost:11434/v1")?
|
||||||
|
.set_default("providers.ollama.enabled", false)?
|
||||||
|
.set_default("providers.ollama.models", Vec::<String>::new())?;
|
||||||
|
|
||||||
|
// Load from config file if exists
|
||||||
|
let config_path = config_path.unwrap_or_else(|| std::env::current_dir().unwrap().join("config.toml"));
|
||||||
|
if config_path.exists() {
|
||||||
|
config_builder = config_builder.add_source(File::from(config_path.clone()).format(FileFormat::Toml));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load from .env file
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
// Load from environment variables (with prefix "LLM_PROXY_")
|
||||||
|
config_builder = config_builder.add_source(
|
||||||
|
config::Environment::with_prefix("LLM_PROXY")
|
||||||
|
.separator("__")
|
||||||
|
.try_parsing(true),
|
||||||
|
);
|
||||||
|
|
||||||
|
let config = config_builder.build()?;
|
||||||
|
|
||||||
|
// Deserialize configuration
|
||||||
|
let server: ServerConfig = config.get("server")?;
|
||||||
|
let database: DatabaseConfig = config.get("database")?;
|
||||||
|
let providers: ProviderConfig = config.get("providers")?;
|
||||||
|
|
||||||
|
// For now, use empty model mapping and pricing (will be populated later)
|
||||||
|
let model_mapping = ModelMappingConfig { patterns: vec![] };
|
||||||
|
let pricing = PricingConfig {
|
||||||
|
openai: vec![],
|
||||||
|
gemini: vec![],
|
||||||
|
deepseek: vec![],
|
||||||
|
grok: vec![],
|
||||||
|
ollama: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Arc::new(AppConfig {
|
||||||
|
server,
|
||||||
|
database,
|
||||||
|
providers,
|
||||||
|
model_mapping,
|
||||||
|
pricing,
|
||||||
|
config_path,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_api_key(&self, provider: &str) -> Result<String> {
|
||||||
|
let env_var = match provider {
|
||||||
|
"openai" => &self.providers.openai.api_key_env,
|
||||||
|
"gemini" => &self.providers.gemini.api_key_env,
|
||||||
|
"deepseek" => &self.providers.deepseek.api_key_env,
|
||||||
|
"grok" => &self.providers.grok.api_key_env,
|
||||||
|
_ => return Err(anyhow::anyhow!("Unknown provider: {}", provider)),
|
||||||
|
};
|
||||||
|
|
||||||
|
std::env::var(env_var)
|
||||||
|
.map_err(|_| anyhow::anyhow!("Environment variable {} not set for {}", env_var, provider))
|
||||||
|
}
|
||||||
|
}
|
||||||
642
src/dashboard/mod.rs
Normal file
642
src/dashboard/mod.rs
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
// Dashboard module for LLM Proxy Gateway
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{ws::{Message, WebSocket, WebSocketUpgrade}, State},
|
||||||
|
response::{IntoResponse, Json},
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::Row;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
// Dashboard state
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct DashboardState {
|
||||||
|
app_state: AppState,
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Response types
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ApiResponse<T> {
|
||||||
|
success: bool,
|
||||||
|
data: Option<T>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> ApiResponse<T> {
|
||||||
|
fn success(data: T) -> Self {
|
||||||
|
Self {
|
||||||
|
success: true,
|
||||||
|
data: Some(data),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error(error: String) -> Self {
|
||||||
|
Self {
|
||||||
|
success: false,
|
||||||
|
data: None,
|
||||||
|
error: Some(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... (keep routes as they are)
|
||||||
|
|
||||||
|
// Dashboard routes
|
||||||
|
pub fn router(state: AppState) -> Router {
|
||||||
|
let dashboard_state = DashboardState {
|
||||||
|
app_state: state,
|
||||||
|
};
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
// Static file serving
|
||||||
|
.nest_service("/", tower_http::services::ServeDir::new("static"))
|
||||||
|
.fallback_service(tower_http::services::ServeDir::new("static"))
|
||||||
|
|
||||||
|
// WebSocket endpoint
|
||||||
|
.route("/ws", get(handle_websocket))
|
||||||
|
|
||||||
|
// API endpoints
|
||||||
|
.route("/api/auth/login", post(handle_login))
|
||||||
|
.route("/api/auth/status", get(handle_auth_status))
|
||||||
|
.route("/api/usage/summary", get(handle_usage_summary))
|
||||||
|
.route("/api/usage/time-series", get(handle_time_series))
|
||||||
|
.route("/api/usage/clients", get(handle_clients_usage))
|
||||||
|
.route("/api/usage/providers", get(handle_providers_usage))
|
||||||
|
.route("/api/clients", get(handle_get_clients).post(handle_create_client))
|
||||||
|
.route("/api/clients/:id", get(handle_get_client).delete(handle_delete_client))
|
||||||
|
.route("/api/clients/:id/usage", get(handle_client_usage))
|
||||||
|
.route("/api/providers", get(handle_get_providers))
|
||||||
|
.route("/api/providers/:name", get(handle_get_provider).put(handle_update_provider))
|
||||||
|
.route("/api/providers/:name/test", post(handle_test_provider))
|
||||||
|
.route("/api/system/health", get(handle_system_health))
|
||||||
|
.route("/api/system/logs", get(handle_system_logs))
|
||||||
|
.route("/api/system/backup", post(handle_system_backup))
|
||||||
|
|
||||||
|
.with_state(dashboard_state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket handler
|
||||||
|
async fn handle_websocket(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
State(state): State<DashboardState>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
ws.on_upgrade(|socket| handle_websocket_connection(socket, state))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_websocket_connection(mut socket: WebSocket, state: DashboardState) {
|
||||||
|
info!("WebSocket connection established");
|
||||||
|
|
||||||
|
// Subscribe to events from the global bus
|
||||||
|
let mut rx = state.app_state.dashboard_tx.subscribe();
|
||||||
|
|
||||||
|
// Send initial connection message
|
||||||
|
let _ = socket.send(Message::Text(
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "connected",
|
||||||
|
"message": "Connected to LLM Proxy Dashboard"
|
||||||
|
}).to_string().into(),
|
||||||
|
)).await;
|
||||||
|
|
||||||
|
// Handle incoming messages and broadcast events
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
// Receive broadcast events
|
||||||
|
Ok(event) = rx.recv() => {
|
||||||
|
let message = Message::Text(serde_json::to_string(&event).unwrap().into());
|
||||||
|
if socket.send(message).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive WebSocket messages
|
||||||
|
result = socket.recv() => {
|
||||||
|
match result {
|
||||||
|
Some(Ok(Message::Text(text))) => {
|
||||||
|
handle_websocket_message(&text, &state).await;
|
||||||
|
}
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("WebSocket connection closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_websocket_message(text: &str, state: &DashboardState) {
|
||||||
|
// Parse and handle WebSocket messages
|
||||||
|
if let Ok(data) = serde_json::from_str::<serde_json::Value>(text) {
|
||||||
|
if let Some("ping") = data.get("type").and_then(|v| v.as_str()) {
|
||||||
|
let _ = state.app_state.dashboard_tx.send(serde_json::json!({
|
||||||
|
"event_type": "pong",
|
||||||
|
"data": {}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication handlers
|
||||||
|
async fn handle_login() -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
// Simple authentication for demo
|
||||||
|
// In production, this would validate credentials against a database
|
||||||
|
Json(ApiResponse::success(serde_json::json!({
|
||||||
|
"token": "demo-token-123456",
|
||||||
|
"user": {
|
||||||
|
"username": "admin",
|
||||||
|
"name": "Administrator",
|
||||||
|
"role": "Super Admin"
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_auth_status() -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
Json(ApiResponse::success(serde_json::json!({
|
||||||
|
"authenticated": true,
|
||||||
|
"user": {
|
||||||
|
"username": "admin",
|
||||||
|
"name": "Administrator",
|
||||||
|
"role": "Super Admin"
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage handlers
|
||||||
|
async fn handle_usage_summary(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
let pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
|
// Total stats
|
||||||
|
let total_stats = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_requests,
|
||||||
|
COALESCE(SUM(total_tokens), 0) as total_tokens,
|
||||||
|
COALESCE(SUM(cost), 0.0) as total_cost,
|
||||||
|
COUNT(DISTINCT client_id) as active_clients
|
||||||
|
FROM llm_requests
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_one(pool);
|
||||||
|
|
||||||
|
// Today's stats
|
||||||
|
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||||
|
let today_stats = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as today_requests,
|
||||||
|
COALESCE(SUM(total_tokens), 0) as today_tokens,
|
||||||
|
COALESCE(SUM(cost), 0.0) as today_cost
|
||||||
|
FROM llm_requests
|
||||||
|
WHERE strftime('%Y-%m-%d', timestamp) = ?
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(today)
|
||||||
|
.fetch_one(pool);
|
||||||
|
|
||||||
|
// Error stats
|
||||||
|
let error_stats = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errors
|
||||||
|
FROM llm_requests
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_one(pool);
|
||||||
|
|
||||||
|
// Average response time
|
||||||
|
let avg_response = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT COALESCE(AVG(duration_ms), 0.0) as avg_duration
|
||||||
|
FROM llm_requests
|
||||||
|
WHERE status = 'success'
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_one(pool);
|
||||||
|
|
||||||
|
match tokio::join!(total_stats, today_stats, error_stats, avg_response) {
|
||||||
|
(Ok(t), Ok(d), Ok(e), Ok(a)) => {
|
||||||
|
let total_requests: i64 = t.get("total_requests");
|
||||||
|
let total_tokens: i64 = t.get("total_tokens");
|
||||||
|
let total_cost: f64 = t.get("total_cost");
|
||||||
|
let active_clients: i64 = t.get("active_clients");
|
||||||
|
|
||||||
|
let today_requests: i64 = d.get("today_requests");
|
||||||
|
let today_cost: f64 = d.get("today_cost");
|
||||||
|
|
||||||
|
let total_count: i64 = e.get("total");
|
||||||
|
let error_count: i64 = e.get("errors");
|
||||||
|
let error_rate = if total_count > 0 {
|
||||||
|
(error_count as f64 / total_count as f64) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let avg_response_time: f64 = a.get("avg_duration");
|
||||||
|
|
||||||
|
Json(ApiResponse::success(serde_json::json!({
|
||||||
|
"total_requests": total_requests,
|
||||||
|
"total_tokens": total_tokens,
|
||||||
|
"total_cost": total_cost,
|
||||||
|
"active_clients": active_clients,
|
||||||
|
"today_requests": today_requests,
|
||||||
|
"today_cost": today_cost,
|
||||||
|
"error_rate": error_rate,
|
||||||
|
"avg_response_time": avg_response_time,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
_ => Json(ApiResponse::error("Failed to fetch usage statistics".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_time_series(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
let pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let twenty_four_hours_ago = now - chrono::Duration::hours(24);
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
strftime('%H:00', timestamp) as hour,
|
||||||
|
COUNT(*) as requests,
|
||||||
|
SUM(total_tokens) as tokens,
|
||||||
|
SUM(cost) as cost
|
||||||
|
FROM llm_requests
|
||||||
|
WHERE timestamp >= ?
|
||||||
|
GROUP BY hour
|
||||||
|
ORDER BY hour
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(twenty_four_hours_ago)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(rows) => {
|
||||||
|
let mut series = Vec::new();
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let hour: String = row.get("hour");
|
||||||
|
let requests: i64 = row.get("requests");
|
||||||
|
let tokens: i64 = row.get("tokens");
|
||||||
|
let cost: f64 = row.get("cost");
|
||||||
|
|
||||||
|
series.push(serde_json::json!({
|
||||||
|
"time": hour,
|
||||||
|
"requests": requests,
|
||||||
|
"tokens": tokens,
|
||||||
|
"cost": cost,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(ApiResponse::success(serde_json::json!({
|
||||||
|
"series": series,
|
||||||
|
"period": "24h"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to fetch time series data: {}", e);
|
||||||
|
Json(ApiResponse::error("Failed to fetch time series data".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_clients_usage(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
// Query database for client usage statistics
|
||||||
|
let pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
client_id,
|
||||||
|
COUNT(*) as requests,
|
||||||
|
SUM(total_tokens) as tokens,
|
||||||
|
SUM(cost) as cost,
|
||||||
|
MAX(timestamp) as last_request
|
||||||
|
FROM llm_requests
|
||||||
|
GROUP BY client_id
|
||||||
|
ORDER BY requests DESC
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(rows) => {
|
||||||
|
let mut client_usage = Vec::new();
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let client_id: String = row.get("client_id");
|
||||||
|
let requests: i64 = row.get("requests");
|
||||||
|
let tokens: i64 = row.get("tokens");
|
||||||
|
let cost: f64 = row.get("cost");
|
||||||
|
let last_request: Option<chrono::DateTime<chrono::Utc>> = row.get("last_request");
|
||||||
|
|
||||||
|
client_usage.push(serde_json::json!({
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_name": client_id,
|
||||||
|
"requests": requests,
|
||||||
|
"tokens": tokens,
|
||||||
|
"cost": cost,
|
||||||
|
"last_request": last_request,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(ApiResponse::success(serde_json::json!(client_usage)))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to fetch client usage data: {}", e);
|
||||||
|
Json(ApiResponse::error("Failed to fetch client usage data".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_providers_usage(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
// Query database for provider usage statistics
|
||||||
|
let pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
provider,
|
||||||
|
COUNT(*) as requests,
|
||||||
|
COALESCE(SUM(total_tokens), 0) as tokens,
|
||||||
|
COALESCE(SUM(cost), 0.0) as cost
|
||||||
|
FROM llm_requests
|
||||||
|
GROUP BY provider
|
||||||
|
ORDER BY requests DESC
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(rows) => {
|
||||||
|
let mut provider_usage = Vec::new();
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let provider: String = row.get("provider");
|
||||||
|
let requests: i64 = row.get("requests");
|
||||||
|
let tokens: i64 = row.get("tokens");
|
||||||
|
let cost: f64 = row.get("cost");
|
||||||
|
|
||||||
|
provider_usage.push(serde_json::json!({
|
||||||
|
"provider": provider,
|
||||||
|
"requests": requests,
|
||||||
|
"tokens": tokens,
|
||||||
|
"cost": cost,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(ApiResponse::success(serde_json::json!(provider_usage)))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to fetch provider usage data: {}", e);
|
||||||
|
Json(ApiResponse::error("Failed to fetch provider usage data".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client handlers
|
||||||
|
async fn handle_get_clients(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
let pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
client_id as id,
|
||||||
|
name,
|
||||||
|
created_at,
|
||||||
|
total_requests,
|
||||||
|
total_tokens,
|
||||||
|
total_cost,
|
||||||
|
is_active
|
||||||
|
FROM clients
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(rows) => {
|
||||||
|
let clients: Vec<serde_json::Value> = rows.into_iter().map(|row| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": row.get::<String, _>("id"),
|
||||||
|
"name": row.get::<Option<String>, _>("name").unwrap_or_else(|| "Unnamed".to_string()),
|
||||||
|
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
|
||||||
|
"requests_count": row.get::<i64, _>("total_requests"),
|
||||||
|
"total_tokens": row.get::<i64, _>("total_tokens"),
|
||||||
|
"total_cost": row.get::<f64, _>("total_cost"),
|
||||||
|
"status": if row.get::<bool, _>("is_active") { "active" } else { "inactive" },
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Json(ApiResponse::success(serde_json::json!(clients)))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to fetch clients: {}", e);
|
||||||
|
Json(ApiResponse::error("Failed to fetch clients".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_create_client() -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
// In production, this would create a real client
|
||||||
|
Json(ApiResponse::success(serde_json::json!({
|
||||||
|
"id": format!("client-{}", rand::random::<u32>()),
|
||||||
|
"name": "New Client",
|
||||||
|
"token": format!("sk-demo-{}", rand::random::<u32>()),
|
||||||
|
"created_at": chrono::Utc::now().to_rfc3339(),
|
||||||
|
"last_used": None::<String>,
|
||||||
|
"requests_count": 0,
|
||||||
|
"status": "active",
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_get_client() -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
Json(ApiResponse::error("Not implemented".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_delete_client() -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
Json(ApiResponse::success(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Client deleted"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_client_usage() -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
Json(ApiResponse::error("Not implemented".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider handlers
|
||||||
|
async fn handle_get_providers(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
let registry = &state.app_state.model_registry;
|
||||||
|
|
||||||
|
let mut providers_json = Vec::new();
|
||||||
|
|
||||||
|
for (p_id, p_info) in ®istry.providers {
|
||||||
|
let models: Vec<String> = p_info.models.keys().cloned().collect();
|
||||||
|
|
||||||
|
// Check if provider is healthy via circuit breaker
|
||||||
|
let status = if state.app_state.rate_limit_manager.check_provider_request(p_id).await.unwrap_or(true) {
|
||||||
|
"online"
|
||||||
|
} else {
|
||||||
|
"degraded"
|
||||||
|
};
|
||||||
|
|
||||||
|
providers_json.push(serde_json::json!({
|
||||||
|
"id": p_id,
|
||||||
|
"name": p_info.name,
|
||||||
|
"enabled": true,
|
||||||
|
"status": status,
|
||||||
|
"models": models,
|
||||||
|
"last_used": null, // TODO: track last used
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Ollama explicitly
|
||||||
|
providers_json.push(serde_json::json!({
|
||||||
|
"id": "ollama",
|
||||||
|
"name": "Ollama",
|
||||||
|
"enabled": true,
|
||||||
|
"status": "online",
|
||||||
|
"models": ["llama3", "mistral", "phi3"],
|
||||||
|
"last_used": null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
Json(ApiResponse::success(serde_json::json!(providers_json)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_get_provider() -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
Json(ApiResponse::error("Not implemented".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_update_provider() -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
Json(ApiResponse::success(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Provider updated"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_test_provider() -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
Json(ApiResponse::success(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"latency": rand::random::<u32>() % 500 + 100,
|
||||||
|
"message": "Connection test successful"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// System handlers
|
||||||
|
async fn handle_system_health(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
let mut components = HashMap::new();
|
||||||
|
components.insert("api_server", "online");
|
||||||
|
components.insert("database", "online");
|
||||||
|
|
||||||
|
// Check provider health via circuit breakers
|
||||||
|
for p_id in state.app_state.model_registry.providers.keys() {
|
||||||
|
if state.app_state.rate_limit_manager.check_provider_request(p_id).await.unwrap_or(true) {
|
||||||
|
components.insert(p_id.as_str(), "online");
|
||||||
|
} else {
|
||||||
|
components.insert(p_id.as_str(), "degraded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Ollama health
|
||||||
|
if state.app_state.rate_limit_manager.check_provider_request("ollama").await.unwrap_or(true) {
|
||||||
|
components.insert("ollama", "online");
|
||||||
|
} else {
|
||||||
|
components.insert("ollama", "degraded");
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(ApiResponse::success(serde_json::json!({
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||||
|
"components": components,
|
||||||
|
"metrics": {
|
||||||
|
"cpu_usage": rand::random::<f64>() * 10.0 + 5.0,
|
||||||
|
"memory_usage": rand::random::<f64>() * 20.0 + 40.0,
|
||||||
|
"active_connections": rand::random::<u32>() % 20 + 5,
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_system_logs(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
let pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
timestamp,
|
||||||
|
client_id,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
total_tokens,
|
||||||
|
cost,
|
||||||
|
status,
|
||||||
|
error_message,
|
||||||
|
duration_ms
|
||||||
|
FROM llm_requests
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 100
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(rows) => {
|
||||||
|
let logs: Vec<serde_json::Value> = rows.into_iter().map(|row| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": row.get::<i64, _>("id"),
|
||||||
|
"timestamp": row.get::<chrono::DateTime<chrono::Utc>, _>("timestamp"),
|
||||||
|
"client_id": row.get::<String, _>("client_id"),
|
||||||
|
"provider": row.get::<String, _>("provider"),
|
||||||
|
"model": row.get::<String, _>("model"),
|
||||||
|
"tokens": row.get::<i64, _>("total_tokens"),
|
||||||
|
"cost": row.get::<f64, _>("cost"),
|
||||||
|
"status": row.get::<String, _>("status"),
|
||||||
|
"error": row.get::<Option<String>, _>("error_message"),
|
||||||
|
"duration": row.get::<i64, _>("duration_ms"),
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Json(ApiResponse::success(serde_json::json!(logs)))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to fetch system logs: {}", e);
|
||||||
|
Json(ApiResponse::error("Failed to fetch system logs".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_system_backup() -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
Json(ApiResponse::success(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Backup initiated",
|
||||||
|
"backup_id": format!("backup-{}", chrono::Utc::now().timestamp()),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn mask_token(token: &str) -> String {
|
||||||
|
if token.len() <= 8 {
|
||||||
|
return "*****".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let masked_len = token.len().min(12);
|
||||||
|
let visible_len = 4;
|
||||||
|
let mask_len = masked_len - visible_len;
|
||||||
|
|
||||||
|
format!("{}{}", "*".repeat(mask_len), &token[token.len() - visible_len..])
|
||||||
|
}
|
||||||
128
src/database/mod.rs
Normal file
128
src/database/mod.rs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::config::DatabaseConfig;
|
||||||
|
|
||||||
|
pub type DbPool = SqlitePool;
|
||||||
|
|
||||||
|
pub async fn init(config: &DatabaseConfig) -> Result<DbPool> {
|
||||||
|
// Ensure the database directory exists
|
||||||
|
if let Some(parent) = config.path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let database_url = format!("sqlite:{}", config.path.display());
|
||||||
|
info!("Connecting to database at {}", database_url);
|
||||||
|
|
||||||
|
let pool = SqlitePool::connect(&database_url).await?;
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
run_migrations(&pool).await?;
|
||||||
|
info!("Database migrations completed");
|
||||||
|
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_migrations(pool: &DbPool) -> Result<()> {
|
||||||
|
// Create clients table if it doesn't exist
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS clients (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
client_id TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
description TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
rate_limit_per_minute INTEGER DEFAULT 60,
|
||||||
|
total_requests INTEGER DEFAULT 0,
|
||||||
|
total_tokens INTEGER DEFAULT 0,
|
||||||
|
total_cost REAL DEFAULT 0.0
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Create llm_requests table if it doesn't exist
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS llm_requests (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
client_id TEXT,
|
||||||
|
provider TEXT,
|
||||||
|
model TEXT,
|
||||||
|
prompt_tokens INTEGER,
|
||||||
|
completion_tokens INTEGER,
|
||||||
|
total_tokens INTEGER,
|
||||||
|
cost REAL,
|
||||||
|
has_images BOOLEAN DEFAULT FALSE,
|
||||||
|
status TEXT DEFAULT 'success',
|
||||||
|
error_message TEXT,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
request_body TEXT,
|
||||||
|
response_body TEXT,
|
||||||
|
FOREIGN KEY (client_id) REFERENCES clients(client_id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Create indices
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_clients_client_id ON clients(client_id)"
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_clients_created_at ON clients(created_at)"
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_llm_requests_timestamp ON llm_requests(timestamp)"
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_llm_requests_client_id ON llm_requests(client_id)"
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_llm_requests_provider ON llm_requests(provider)"
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_llm_requests_status ON llm_requests(status)"
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Insert default client if none exists
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT OR IGNORE INTO clients (client_id, name, description)
|
||||||
|
VALUES ('default', 'Default Client', 'Default client for anonymous requests')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn test_connection(pool: &DbPool) -> Result<()> {
|
||||||
|
sqlx::query("SELECT 1").execute(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
58
src/errors/mod.rs
Normal file
58
src/errors/mod.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug, Clone)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("Authentication failed: {0}")]
|
||||||
|
AuthError(String),
|
||||||
|
|
||||||
|
#[error("Configuration error: {0}")]
|
||||||
|
ConfigError(String),
|
||||||
|
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
DatabaseError(String),
|
||||||
|
|
||||||
|
#[error("Provider error: {0}")]
|
||||||
|
ProviderError(String),
|
||||||
|
|
||||||
|
#[error("Validation error: {0}")]
|
||||||
|
ValidationError(String),
|
||||||
|
|
||||||
|
#[error("Multimodal processing error: {0}")]
|
||||||
|
MultimodalError(String),
|
||||||
|
|
||||||
|
#[error("Rate limit exceeded: {0}")]
|
||||||
|
RateLimitError(String),
|
||||||
|
|
||||||
|
#[error("Internal server error: {0}")]
|
||||||
|
InternalError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for AppError {
|
||||||
|
fn from(err: sqlx::Error) -> Self {
|
||||||
|
AppError::DatabaseError(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<anyhow::Error> for AppError {
|
||||||
|
fn from(err: anyhow::Error) -> Self {
|
||||||
|
AppError::InternalError(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl axum::response::IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
let status = match self {
|
||||||
|
AppError::AuthError(_) => axum::http::StatusCode::UNAUTHORIZED,
|
||||||
|
AppError::RateLimitError(_) => axum::http::StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
AppError::ValidationError(_) => axum::http::StatusCode::BAD_REQUEST,
|
||||||
|
_ => axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = axum::Json(serde_json::json!({
|
||||||
|
"error": self.to_string(),
|
||||||
|
"type": format!("{:?}", self)
|
||||||
|
}));
|
||||||
|
|
||||||
|
(status, body).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/lib.rs
Normal file
89
src/lib.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
//! LLM Proxy Library
|
||||||
|
//!
|
||||||
|
//! This library provides the core functionality for the LLM proxy gateway,
|
||||||
|
//! including provider integration, token tracking, and API endpoints.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod client;
|
||||||
|
pub mod config;
|
||||||
|
pub mod database;
|
||||||
|
pub mod dashboard;
|
||||||
|
pub mod errors;
|
||||||
|
pub mod logging;
|
||||||
|
pub mod models;
|
||||||
|
pub mod multimodal;
|
||||||
|
pub mod providers;
|
||||||
|
pub mod rate_limiting;
|
||||||
|
pub mod server;
|
||||||
|
pub mod state;
|
||||||
|
pub mod utils;
|
||||||
|
|
||||||
|
// Re-exports for convenience
|
||||||
|
pub use auth::*;
|
||||||
|
pub use config::*;
|
||||||
|
pub use database::*;
|
||||||
|
pub use errors::*;
|
||||||
|
pub use logging::*;
|
||||||
|
pub use models::*;
|
||||||
|
pub use providers::*;
|
||||||
|
pub use server::*;
|
||||||
|
pub use state::*;
|
||||||
|
|
||||||
|
/// Test utilities for integration testing
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod test_utils {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
state::AppState,
|
||||||
|
rate_limiting::RateLimitManager,
|
||||||
|
client::ClientManager,
|
||||||
|
providers::ProviderManager,
|
||||||
|
};
|
||||||
|
use sqlx::sqlite::SqlitePool;
|
||||||
|
|
||||||
|
/// Create a test application state
|
||||||
|
pub async fn create_test_state() -> Arc<AppState> {
|
||||||
|
// Create in-memory database
|
||||||
|
let pool = SqlitePool::connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("Failed to create test database");
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
crate::database::init(&crate::config::DatabaseConfig {
|
||||||
|
path: std::path::PathBuf::from(":memory:"),
|
||||||
|
max_connections: 5,
|
||||||
|
}).await.expect("Failed to initialize test database");
|
||||||
|
|
||||||
|
let rate_limit_manager = RateLimitManager::new(
|
||||||
|
crate::rate_limiting::RateLimiterConfig::default(),
|
||||||
|
crate::rate_limiting::CircuitBreakerConfig::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let client_manager = Arc::new(ClientManager::new(pool.clone()));
|
||||||
|
|
||||||
|
// Create provider manager
|
||||||
|
let provider_manager = ProviderManager::new();
|
||||||
|
|
||||||
|
let model_registry = crate::models::registry::ModelRegistry {
|
||||||
|
providers: std::collections::HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Arc::new(AppState {
|
||||||
|
provider_manager,
|
||||||
|
db_pool: pool.clone(),
|
||||||
|
rate_limit_manager: Arc::new(rate_limit_manager),
|
||||||
|
client_manager,
|
||||||
|
request_logger: Arc::new(crate::logging::RequestLogger::new(pool.clone())),
|
||||||
|
model_registry: Arc::new(model_registry),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a test HTTP client
|
||||||
|
pub fn create_test_client() -> reqwest::Client {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create test HTTP client")
|
||||||
|
}
|
||||||
|
}
|
||||||
186
src/logging/mod.rs
Normal file
186
src/logging/mod.rs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use tracing::warn;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::errors::AppError;
|
||||||
|
|
||||||
|
/// Request log entry for database storage
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct RequestLog {
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
pub client_id: String,
|
||||||
|
pub provider: String,
|
||||||
|
pub model: String,
|
||||||
|
pub prompt_tokens: u32,
|
||||||
|
pub completion_tokens: u32,
|
||||||
|
pub total_tokens: u32,
|
||||||
|
pub cost: f64,
|
||||||
|
pub has_images: bool,
|
||||||
|
pub status: String, // "success", "error"
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
pub duration_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Database operations for request logging
|
||||||
|
pub struct RequestLogger {
|
||||||
|
db_pool: SqlitePool,
|
||||||
|
dashboard_tx: broadcast::Sender<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestLogger {
|
||||||
|
pub fn new(db_pool: SqlitePool, dashboard_tx: broadcast::Sender<serde_json::Value>) -> Self {
|
||||||
|
Self { db_pool, dashboard_tx }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log a request to the database (async, spawns a task)
|
||||||
|
pub fn log_request(&self, log: RequestLog) {
|
||||||
|
let pool = self.db_pool.clone();
|
||||||
|
let tx = self.dashboard_tx.clone();
|
||||||
|
|
||||||
|
// Spawn async task to log without blocking response
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Broadcast to dashboard
|
||||||
|
let _ = tx.send(serde_json::json!({
|
||||||
|
"event_type": "request",
|
||||||
|
"data": log
|
||||||
|
}));
|
||||||
|
|
||||||
|
if let Err(e) = Self::insert_log(&pool, log).await {
|
||||||
|
warn!("Failed to log request to database: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a log entry into the database
|
||||||
|
async fn insert_log(pool: &SqlitePool, log: RequestLog) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO llm_requests
|
||||||
|
(timestamp, client_id, provider, model, prompt_tokens, completion_tokens, total_tokens, cost, has_images, status, error_message, duration_ms, request_body, response_body)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(log.timestamp)
|
||||||
|
.bind(log.client_id)
|
||||||
|
.bind(log.provider)
|
||||||
|
.bind(log.model)
|
||||||
|
.bind(log.prompt_tokens as i64)
|
||||||
|
.bind(log.completion_tokens as i64)
|
||||||
|
.bind(log.total_tokens as i64)
|
||||||
|
.bind(log.cost)
|
||||||
|
.bind(log.has_images)
|
||||||
|
.bind(log.status)
|
||||||
|
.bind(log.error_message)
|
||||||
|
.bind(log.duration_ms as i64)
|
||||||
|
.bind(None::<String>) // request_body - TODO: store serialized request
|
||||||
|
.bind(None::<String>) // response_body - TODO: store serialized response or error
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /// Middleware to log LLM API requests
|
||||||
|
// /// TODO: Implement proper middleware that can extract response body details
|
||||||
|
// pub async fn request_logging_middleware(
|
||||||
|
// // Extract the authenticated client (if available)
|
||||||
|
// auth_result: Result<AuthenticatedClient, AppError>,
|
||||||
|
// request: Request,
|
||||||
|
// next: Next,
|
||||||
|
// ) -> Response {
|
||||||
|
// let start_time = std::time::Instant::now();
|
||||||
|
//
|
||||||
|
// // Extract client_id from auth or use "unknown"
|
||||||
|
// let client_id = match auth_result {
|
||||||
|
// Ok(auth) => auth.client_id,
|
||||||
|
// Err(_) => "unknown".to_string(),
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// // Try to extract request details
|
||||||
|
// let (request_parts, request_body) = request.into_parts();
|
||||||
|
//
|
||||||
|
// // Clone request parts for logging
|
||||||
|
// let path = request_parts.uri.path().to_string();
|
||||||
|
//
|
||||||
|
// // Check if this is a chat completion request
|
||||||
|
// let is_chat_completion = path == "/v1/chat/completions";
|
||||||
|
//
|
||||||
|
// // Reconstruct request for downstream handlers
|
||||||
|
// let request = Request::from_parts(request_parts, request_body);
|
||||||
|
//
|
||||||
|
// // Process request and get response
|
||||||
|
// let response = next.run(request).await;
|
||||||
|
//
|
||||||
|
// // Calculate duration
|
||||||
|
// let duration = start_time.elapsed();
|
||||||
|
// let duration_ms = duration.as_millis() as u64;
|
||||||
|
//
|
||||||
|
// // Log basic request info
|
||||||
|
// info!(
|
||||||
|
// "Request from {} to {} - Status: {} - Duration: {}ms",
|
||||||
|
// client_id,
|
||||||
|
// path,
|
||||||
|
// response.status().as_u16(),
|
||||||
|
// duration_ms
|
||||||
|
// );
|
||||||
|
//
|
||||||
|
// // TODO: Extract more details from request/response for logging
|
||||||
|
// // For now, we'll need to modify the server handler to pass additional context
|
||||||
|
//
|
||||||
|
// response
|
||||||
|
// }
|
||||||
|
|
||||||
|
/// Context for request logging that can be passed through extensions
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct LoggingContext {
|
||||||
|
pub client_id: String,
|
||||||
|
pub provider_name: String,
|
||||||
|
pub model: String,
|
||||||
|
pub prompt_tokens: u32,
|
||||||
|
pub completion_tokens: u32,
|
||||||
|
pub total_tokens: u32,
|
||||||
|
pub cost: f64,
|
||||||
|
pub has_images: bool,
|
||||||
|
pub error: Option<AppError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoggingContext {
|
||||||
|
pub fn new(client_id: String, provider_name: String, model: String) -> Self {
|
||||||
|
Self {
|
||||||
|
client_id,
|
||||||
|
provider_name,
|
||||||
|
model,
|
||||||
|
prompt_tokens: 0,
|
||||||
|
completion_tokens: 0,
|
||||||
|
total_tokens: 0,
|
||||||
|
cost: 0.0,
|
||||||
|
has_images: false,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_token_counts(mut self, prompt_tokens: u32, completion_tokens: u32) -> Self {
|
||||||
|
self.prompt_tokens = prompt_tokens;
|
||||||
|
self.completion_tokens = completion_tokens;
|
||||||
|
self.total_tokens = prompt_tokens + completion_tokens;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_cost(mut self, cost: f64) -> Self {
|
||||||
|
self.cost = cost;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_images(mut self, has_images: bool) -> Self {
|
||||||
|
self.has_images = has_images;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_error(mut self, error: AppError) -> Self {
|
||||||
|
self.error = Some(error);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/main.rs
Normal file
141
src/main.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use axum::{Router, routing::get};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tracing::{info, error};
|
||||||
|
|
||||||
|
use llm_proxy::{
|
||||||
|
config::AppConfig,
|
||||||
|
state::AppState,
|
||||||
|
providers::{
|
||||||
|
ProviderManager,
|
||||||
|
openai::OpenAIProvider,
|
||||||
|
gemini::GeminiProvider,
|
||||||
|
deepseek::DeepSeekProvider,
|
||||||
|
grok::GrokProvider,
|
||||||
|
ollama::OllamaProvider,
|
||||||
|
},
|
||||||
|
database,
|
||||||
|
server,
|
||||||
|
dashboard,
|
||||||
|
rate_limiting::{RateLimitManager, RateLimiterConfig, CircuitBreakerConfig},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
// Initialize tracing (logging)
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_max_level(tracing::Level::INFO)
|
||||||
|
.with_target(false)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
info!("Starting LLM Proxy Gateway v{}", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
let config = AppConfig::load().await?;
|
||||||
|
info!("Configuration loaded from {:?}", config.config_path);
|
||||||
|
|
||||||
|
// Initialize database connection pool
|
||||||
|
let db_pool = database::init(&config.database).await?;
|
||||||
|
info!("Database initialized at {:?}", config.database.path);
|
||||||
|
|
||||||
|
// Initialize provider manager with configured providers
|
||||||
|
let mut provider_manager = ProviderManager::new();
|
||||||
|
|
||||||
|
// Initialize OpenAI
|
||||||
|
if config.providers.openai.enabled {
|
||||||
|
match OpenAIProvider::new(&config.providers.openai, &config) {
|
||||||
|
Ok(p) => {
|
||||||
|
provider_manager.add_provider(Arc::new(p));
|
||||||
|
info!("OpenAI provider initialized");
|
||||||
|
}
|
||||||
|
Err(e) => error!("Failed to initialize OpenAI provider: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Gemini
|
||||||
|
if config.providers.gemini.enabled {
|
||||||
|
match GeminiProvider::new(&config.providers.gemini, &config) {
|
||||||
|
Ok(p) => {
|
||||||
|
provider_manager.add_provider(Arc::new(p));
|
||||||
|
info!("Gemini provider initialized");
|
||||||
|
}
|
||||||
|
Err(e) => error!("Failed to initialize Gemini provider: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize DeepSeek
|
||||||
|
if config.providers.deepseek.enabled {
|
||||||
|
match DeepSeekProvider::new(&config.providers.deepseek, &config) {
|
||||||
|
Ok(p) => {
|
||||||
|
provider_manager.add_provider(Arc::new(p));
|
||||||
|
info!("DeepSeek provider initialized");
|
||||||
|
}
|
||||||
|
Err(e) => error!("Failed to initialize DeepSeek provider: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Grok
|
||||||
|
if config.providers.grok.enabled {
|
||||||
|
match GrokProvider::new(&config.providers.grok, &config) {
|
||||||
|
Ok(p) => {
|
||||||
|
provider_manager.add_provider(Arc::new(p));
|
||||||
|
info!("Grok provider initialized");
|
||||||
|
}
|
||||||
|
Err(e) => error!("Failed to initialize Grok provider: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Ollama
|
||||||
|
if config.providers.ollama.enabled {
|
||||||
|
match OllamaProvider::new(&config.providers.ollama, &config) {
|
||||||
|
Ok(p) => {
|
||||||
|
provider_manager.add_provider(Arc::new(p));
|
||||||
|
info!("Ollama provider initialized at {}", config.providers.ollama.base_url);
|
||||||
|
}
|
||||||
|
Err(e) => error!("Failed to initialize Ollama provider: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create rate limit manager
|
||||||
|
let rate_limit_manager = RateLimitManager::new(
|
||||||
|
RateLimiterConfig::default(),
|
||||||
|
CircuitBreakerConfig::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch model registry from models.dev
|
||||||
|
let model_registry = match llm_proxy::utils::registry::fetch_registry().await {
|
||||||
|
Ok(registry) => registry,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to fetch model registry: {}. Using empty registry.", e);
|
||||||
|
llm_proxy::models::registry::ModelRegistry { providers: std::collections::HashMap::new() }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create application state
|
||||||
|
let state = AppState::new(provider_manager, db_pool, rate_limit_manager, model_registry);
|
||||||
|
|
||||||
|
// Create application router
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/health", get(health_check))
|
||||||
|
.route("/", get(root))
|
||||||
|
.merge(server::router(state.clone()))
|
||||||
|
.merge(dashboard::router(state.clone()));
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
let addr = SocketAddr::from(([0, 0, 0, 0], config.server.port));
|
||||||
|
info!("Server listening on http://{}", addr);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health_check() -> &'static str {
|
||||||
|
"OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn root() -> &'static str {
|
||||||
|
"LLM Proxy Gateway - Unified interface for OpenAI, Gemini, DeepSeek, and Grok"
|
||||||
|
}
|
||||||
247
src/models/mod.rs
Normal file
247
src/models/mod.rs
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub mod registry;
|
||||||
|
|
||||||
|
// ========== OpenAI-compatible Request/Response Structs ==========
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatCompletionRequest {
|
||||||
|
pub model: String,
|
||||||
|
pub messages: Vec<ChatMessage>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub temperature: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_tokens: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stream: Option<bool>,
|
||||||
|
// Add other OpenAI-compatible fields as needed
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
pub role: String, // "system", "user", "assistant"
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub content: MessageContent,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reasoning_content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum MessageContent {
|
||||||
|
Text { content: String },
|
||||||
|
Parts { content: Vec<ContentPartValue> },
|
||||||
|
None, // Handle cases where content might be null but reasoning is present
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ContentPartValue {
|
||||||
|
Text { text: String },
|
||||||
|
ImageUrl { image_url: ImageUrl },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ImageUrl {
|
||||||
|
pub url: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub detail: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatCompletionResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub object: String,
|
||||||
|
pub created: u64,
|
||||||
|
pub model: String,
|
||||||
|
pub choices: Vec<ChatChoice>,
|
||||||
|
pub usage: Option<Usage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatChoice {
|
||||||
|
pub index: u32,
|
||||||
|
pub message: ChatMessage,
|
||||||
|
pub finish_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Usage {
|
||||||
|
pub prompt_tokens: u32,
|
||||||
|
pub completion_tokens: u32,
|
||||||
|
pub total_tokens: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Streaming Response Structs ==========
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatCompletionStreamResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub object: String,
|
||||||
|
pub created: u64,
|
||||||
|
pub model: String,
|
||||||
|
pub choices: Vec<ChatStreamChoice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatStreamChoice {
|
||||||
|
pub index: u32,
|
||||||
|
pub delta: ChatStreamDelta,
|
||||||
|
pub finish_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatStreamDelta {
|
||||||
|
pub role: Option<String>,
|
||||||
|
pub content: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reasoning_content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Unified Request Format (for internal use) ==========
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UnifiedRequest {
|
||||||
|
pub client_id: String,
|
||||||
|
pub model: String,
|
||||||
|
pub messages: Vec<UnifiedMessage>,
|
||||||
|
pub temperature: Option<f64>,
|
||||||
|
pub max_tokens: Option<u32>,
|
||||||
|
pub stream: bool,
|
||||||
|
pub has_images: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UnifiedMessage {
|
||||||
|
pub role: String,
|
||||||
|
pub content: Vec<ContentPart>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ContentPart {
|
||||||
|
Text { text: String },
|
||||||
|
Image(crate::multimodal::ImageInput),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Provider-specific Structs ==========
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct OpenAIRequest {
|
||||||
|
pub model: String,
|
||||||
|
pub messages: Vec<OpenAIMessage>,
|
||||||
|
pub temperature: Option<f64>,
|
||||||
|
pub max_tokens: Option<u32>,
|
||||||
|
pub stream: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct OpenAIMessage {
|
||||||
|
pub role: String,
|
||||||
|
pub content: Vec<OpenAIContentPart>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum OpenAIContentPart {
|
||||||
|
Text { text: String },
|
||||||
|
ImageUrl { image_url: ImageUrl },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: ImageUrl struct is defined earlier in the file
|
||||||
|
|
||||||
|
// ========== Conversion Traits ==========
|
||||||
|
|
||||||
|
pub trait ToOpenAI {
|
||||||
|
fn to_openai(&self) -> Result<OpenAIRequest, anyhow::Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait FromOpenAI {
|
||||||
|
fn from_openai(request: &OpenAIRequest) -> Result<Self, anyhow::Error>
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnifiedRequest {
|
||||||
|
/// Hydrate all image content by fetching URLs and converting to base64/bytes
|
||||||
|
pub async fn hydrate_images(&mut self) -> anyhow::Result<()> {
|
||||||
|
if !self.has_images {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for msg in &mut self.messages {
|
||||||
|
for part in &mut msg.content {
|
||||||
|
if let ContentPart::Image(image_input) = part {
|
||||||
|
// Pre-fetch and validate if it's a URL
|
||||||
|
if let crate::multimodal::ImageInput::Url(_url) = image_input {
|
||||||
|
let (base64_data, mime_type) = image_input.to_base64().await?;
|
||||||
|
*image_input = crate::multimodal::ImageInput::Base64 {
|
||||||
|
data: base64_data,
|
||||||
|
mime_type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<ChatCompletionRequest> for UnifiedRequest {
|
||||||
|
type Error = anyhow::Error;
|
||||||
|
|
||||||
|
fn try_from(req: ChatCompletionRequest) -> Result<Self, Self::Error> {
|
||||||
|
let mut has_images = false;
|
||||||
|
|
||||||
|
// Convert OpenAI-compatible request to unified format
|
||||||
|
let messages = req
|
||||||
|
.messages
|
||||||
|
.into_iter()
|
||||||
|
.map(|msg| {
|
||||||
|
let (content, _images_in_message) = match msg.content {
|
||||||
|
MessageContent::Text { content } => {
|
||||||
|
(vec![ContentPart::Text { text: content }], false)
|
||||||
|
}
|
||||||
|
MessageContent::Parts { content } => {
|
||||||
|
let mut unified_content = Vec::new();
|
||||||
|
let mut has_images_in_msg = false;
|
||||||
|
|
||||||
|
for part in content {
|
||||||
|
match part {
|
||||||
|
ContentPartValue::Text { text } => {
|
||||||
|
unified_content.push(ContentPart::Text { text });
|
||||||
|
}
|
||||||
|
ContentPartValue::ImageUrl { image_url } => {
|
||||||
|
has_images_in_msg = true;
|
||||||
|
has_images = true;
|
||||||
|
unified_content.push(ContentPart::Image(
|
||||||
|
crate::multimodal::ImageInput::from_url(image_url.url)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(unified_content, has_images_in_msg)
|
||||||
|
}
|
||||||
|
MessageContent::None => {
|
||||||
|
(vec![], false)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
UnifiedMessage {
|
||||||
|
role: msg.role,
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(UnifiedRequest {
|
||||||
|
client_id: String::new(), // Will be populated by auth middleware
|
||||||
|
model: req.model,
|
||||||
|
messages,
|
||||||
|
temperature: req.temperature,
|
||||||
|
max_tokens: req.max_tokens,
|
||||||
|
stream: req.stream.unwrap_or(false),
|
||||||
|
has_images,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/models/registry.rs
Normal file
69
src/models/registry.rs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ModelRegistry {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub providers: HashMap<String, ProviderInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProviderInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub models: HashMap<String, ModelMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ModelMetadata {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub cost: Option<ModelCost>,
|
||||||
|
pub limit: Option<ModelLimit>,
|
||||||
|
pub modalities: Option<ModelModalities>,
|
||||||
|
pub tool_call: Option<bool>,
|
||||||
|
pub reasoning: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ModelCost {
|
||||||
|
pub input: f64,
|
||||||
|
pub output: f64,
|
||||||
|
pub cache_read: Option<f64>,
|
||||||
|
pub cache_write: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ModelLimit {
|
||||||
|
pub context: u32,
|
||||||
|
pub output: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ModelModalities {
|
||||||
|
pub input: Vec<String>,
|
||||||
|
pub output: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelRegistry {
|
||||||
|
/// Find a model by its ID (searching across all providers)
|
||||||
|
pub fn find_model(&self, model_id: &str) -> Option<&ModelMetadata> {
|
||||||
|
// First try exact match if the key in models map matches the ID
|
||||||
|
for provider in self.providers.values() {
|
||||||
|
if let Some(model) = provider.models.get(model_id) {
|
||||||
|
return Some(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try searching for the model ID inside the metadata if the key was different
|
||||||
|
for provider in self.providers.values() {
|
||||||
|
for model in provider.models.values() {
|
||||||
|
if model.id == model_id {
|
||||||
|
return Some(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
285
src/multimodal/mod.rs
Normal file
285
src/multimodal/mod.rs
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
//! Multimodal support for image processing and conversion
|
||||||
|
//!
|
||||||
|
//! This module handles:
|
||||||
|
//! 1. Image format detection and conversion
|
||||||
|
//! 2. Base64 encoding/decoding
|
||||||
|
//! 3. URL fetching for images
|
||||||
|
//! 4. Provider-specific image format conversion
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use base64::{engine::general_purpose, Engine as _};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
/// Supported image formats for multimodal input
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ImageInput {
|
||||||
|
/// Base64-encoded image data with MIME type
|
||||||
|
Base64 {
|
||||||
|
data: String,
|
||||||
|
mime_type: String,
|
||||||
|
},
|
||||||
|
/// URL to fetch image from
|
||||||
|
Url(String),
|
||||||
|
/// Raw bytes with MIME type
|
||||||
|
Bytes {
|
||||||
|
data: Vec<u8>,
|
||||||
|
mime_type: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageInput {
|
||||||
|
/// Create ImageInput from base64 string
|
||||||
|
pub fn from_base64(data: String, mime_type: String) -> Self {
|
||||||
|
Self::Base64 { data, mime_type }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create ImageInput from URL
|
||||||
|
pub fn from_url(url: String) -> Self {
|
||||||
|
Self::Url(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create ImageInput from raw bytes
|
||||||
|
pub fn from_bytes(data: Vec<u8>, mime_type: String) -> Self {
|
||||||
|
Self::Bytes { data, mime_type }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get MIME type if available
|
||||||
|
pub fn mime_type(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Self::Base64 { mime_type, .. } => Some(mime_type),
|
||||||
|
Self::Bytes { mime_type, .. } => Some(mime_type),
|
||||||
|
Self::Url(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to base64 if not already
|
||||||
|
pub async fn to_base64(&self) -> Result<(String, String)> {
|
||||||
|
match self {
|
||||||
|
Self::Base64 { data, mime_type } => Ok((data.clone(), mime_type.clone())),
|
||||||
|
Self::Bytes { data, mime_type } => {
|
||||||
|
let base64_data = general_purpose::STANDARD.encode(data);
|
||||||
|
Ok((base64_data, mime_type.clone()))
|
||||||
|
}
|
||||||
|
Self::Url(url) => {
|
||||||
|
// Fetch image from URL
|
||||||
|
info!("Fetching image from URL: {}", url);
|
||||||
|
let response = reqwest::get(url)
|
||||||
|
.await
|
||||||
|
.context("Failed to fetch image from URL")?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
anyhow::bail!("Failed to fetch image: HTTP {}", response.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mime_type = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.unwrap_or("image/jpeg")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let bytes = response.bytes().await.context("Failed to read image bytes")?;
|
||||||
|
|
||||||
|
let base64_data = general_purpose::STANDARD.encode(&bytes);
|
||||||
|
Ok((base64_data, mime_type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get image dimensions (width, height)
|
||||||
|
pub async fn get_dimensions(&self) -> Result<(u32, u32)> {
|
||||||
|
let bytes = match self {
|
||||||
|
Self::Base64 { data, .. } => {
|
||||||
|
general_purpose::STANDARD.decode(data).context("Failed to decode base64")?
|
||||||
|
}
|
||||||
|
Self::Bytes { data, .. } => data.clone(),
|
||||||
|
Self::Url(_) => {
|
||||||
|
let (base64_data, _) = self.to_base64().await?;
|
||||||
|
general_purpose::STANDARD.decode(&base64_data).context("Failed to decode base64")?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let img = image::load_from_memory(&bytes).context("Failed to load image from bytes")?;
|
||||||
|
Ok((img.width(), img.height()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate image size and format
|
||||||
|
pub async fn validate(&self, max_size_mb: f64) -> Result<()> {
|
||||||
|
let (width, height) = self.get_dimensions().await?;
|
||||||
|
|
||||||
|
// Check dimensions
|
||||||
|
if width > 4096 || height > 4096 {
|
||||||
|
warn!("Image dimensions too large: {}x{}", width, height);
|
||||||
|
// Continue anyway, but log warning
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size
|
||||||
|
let size_bytes = match self {
|
||||||
|
Self::Base64 { data, .. } => {
|
||||||
|
// Base64 size is ~4/3 of original
|
||||||
|
(data.len() as f64 * 0.75) as usize
|
||||||
|
}
|
||||||
|
Self::Bytes { data, .. } => data.len(),
|
||||||
|
Self::Url(_) => {
|
||||||
|
// For URLs, we'd need to fetch to check size
|
||||||
|
// Skip size check for URLs for now
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let size_mb = size_bytes as f64 / (1024.0 * 1024.0);
|
||||||
|
if size_mb > max_size_mb {
|
||||||
|
anyhow::bail!("Image too large: {:.2}MB > {:.2}MB limit", size_mb, max_size_mb);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider-specific image format conversion
|
||||||
|
pub struct ImageConverter;
|
||||||
|
|
||||||
|
impl ImageConverter {
|
||||||
|
/// Convert image to OpenAI-compatible format
|
||||||
|
pub async fn to_openai_format(image: &ImageInput) -> Result<serde_json::Value> {
|
||||||
|
let (base64_data, mime_type) = image.to_base64().await?;
|
||||||
|
|
||||||
|
// OpenAI expects data URL format: "data:image/jpeg;base64,{data}"
|
||||||
|
let data_url = format!("data:{};base64,{}", mime_type, base64_data);
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": data_url,
|
||||||
|
"detail": "auto" // Can be "low", "high", or "auto"
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert image to Gemini-compatible format
|
||||||
|
pub async fn to_gemini_format(image: &ImageInput) -> Result<serde_json::Value> {
|
||||||
|
let (base64_data, mime_type) = image.to_base64().await?;
|
||||||
|
|
||||||
|
// Gemini expects inline data format
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"inline_data": {
|
||||||
|
"mime_type": mime_type,
|
||||||
|
"data": base64_data
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert image to DeepSeek-compatible format
|
||||||
|
pub async fn to_deepseek_format(image: &ImageInput) -> Result<serde_json::Value> {
|
||||||
|
// DeepSeek uses OpenAI-compatible format for vision models
|
||||||
|
Self::to_openai_format(image).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect if a model supports multimodal input
|
||||||
|
pub fn model_supports_multimodal(model: &str) -> bool {
|
||||||
|
// OpenAI vision models
|
||||||
|
if (model.starts_with("gpt-4") && (model.contains("vision") || model.contains("-v") || model.contains("4o"))) ||
|
||||||
|
model.starts_with("o1-") || model.starts_with("o3-") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini vision models
|
||||||
|
if model.starts_with("gemini") {
|
||||||
|
// Most Gemini models support vision
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepSeek vision models
|
||||||
|
if model.starts_with("deepseek-vl") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse OpenAI-compatible multimodal message content
|
||||||
|
pub fn parse_openai_content(content: &serde_json::Value) -> Result<Vec<(String, Option<ImageInput>)>> {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
|
||||||
|
if let Some(content_str) = content.as_str() {
|
||||||
|
// Simple text content
|
||||||
|
parts.push((content_str.to_string(), None));
|
||||||
|
} else if let Some(content_array) = content.as_array() {
|
||||||
|
// Array of content parts (text and/or images)
|
||||||
|
for part in content_array {
|
||||||
|
if let Some(part_obj) = part.as_object() {
|
||||||
|
if let Some(part_type) = part_obj.get("type").and_then(|t| t.as_str()) {
|
||||||
|
match part_type {
|
||||||
|
"text" => {
|
||||||
|
if let Some(text) = part_obj.get("text").and_then(|t| t.as_str()) {
|
||||||
|
parts.push((text.to_string(), None));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"image_url" => {
|
||||||
|
if let Some(image_url_obj) = part_obj.get("image_url").and_then(|o| o.as_object()) {
|
||||||
|
if let Some(url) = image_url_obj.get("url").and_then(|u| u.as_str()) {
|
||||||
|
if url.starts_with("data:") {
|
||||||
|
// Parse data URL
|
||||||
|
if let Some((mime_type, data)) = parse_data_url(url) {
|
||||||
|
let image_input = ImageInput::from_base64(data, mime_type);
|
||||||
|
parts.push(("".to_string(), Some(image_input)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular URL
|
||||||
|
let image_input = ImageInput::from_url(url.to_string());
|
||||||
|
parts.push(("".to_string(), Some(image_input)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
warn!("Unknown content part type: {}", part_type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse data URL (data:image/jpeg;base64,{data})
|
||||||
|
fn parse_data_url(data_url: &str) -> Option<(String, String)> {
|
||||||
|
if !data_url.starts_with("data:") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts: Vec<&str> = data_url[5..].split(";base64,").collect();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mime_type = parts[0].to_string();
|
||||||
|
let data = parts[1].to_string();
|
||||||
|
|
||||||
|
Some((mime_type, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_parse_data_url() {
|
||||||
|
let test_url = "data:image/jpeg;base64,SGVsbG8gV29ybGQ="; // "Hello World" in base64
|
||||||
|
let (mime_type, data) = parse_data_url(test_url).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(mime_type, "image/jpeg");
|
||||||
|
assert_eq!(data, "SGVsbG8gV29ybGQ=");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_model_supports_multimodal() {
|
||||||
|
assert!(ImageConverter::model_supports_multimodal("gpt-4-vision-preview"));
|
||||||
|
assert!(ImageConverter::model_supports_multimodal("gemini-pro-vision"));
|
||||||
|
assert!(!ImageConverter::model_supports_multimodal("gpt-3.5-turbo"));
|
||||||
|
assert!(!ImageConverter::model_supports_multimodal("gemini-pro"));
|
||||||
|
}
|
||||||
|
}
|
||||||
209
src/providers/deepseek.rs
Normal file
209
src/providers/deepseek.rs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use anyhow::Result;
|
||||||
|
use futures::stream::{BoxStream, StreamExt};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
models::UnifiedRequest,
|
||||||
|
errors::AppError,
|
||||||
|
config::AppConfig,
|
||||||
|
};
|
||||||
|
use super::{ProviderResponse, ProviderStreamChunk};
|
||||||
|
|
||||||
|
pub struct DeepSeekProvider {
|
||||||
|
client: reqwest::Client,
|
||||||
|
config: crate::config::DeepSeekConfig,
|
||||||
|
api_key: String,
|
||||||
|
pricing: Vec<crate::config::ModelPricing>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeepSeekProvider {
|
||||||
|
pub fn new(config: &crate::config::DeepSeekConfig, app_config: &AppConfig) -> Result<Self> {
|
||||||
|
let api_key = app_config.get_api_key("deepseek")?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
config: config.clone(),
|
||||||
|
api_key,
|
||||||
|
pricing: app_config.pricing.deepseek.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl super::Provider for DeepSeekProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"deepseek"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_model(&self, model: &str) -> bool {
|
||||||
|
model.starts_with("deepseek-") || model.contains("deepseek")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_multimodal(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completion(
|
||||||
|
&self,
|
||||||
|
request: UnifiedRequest,
|
||||||
|
) -> Result<ProviderResponse, AppError> {
|
||||||
|
// Build the OpenAI-compatible body
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"model": request.model,
|
||||||
|
"messages": request.messages.iter().map(|m| {
|
||||||
|
serde_json::json!({
|
||||||
|
"role": m.role,
|
||||||
|
"content": m.content.iter().map(|p| {
|
||||||
|
match p {
|
||||||
|
crate::models::ContentPart::Text { text } => serde_json::json!({ "type": "text", "text": text }),
|
||||||
|
crate::models::ContentPart::Image(image_input) => {
|
||||||
|
// DeepSeek currently doesn't support images in the same way, but we'll try to be standard
|
||||||
|
let (base64_data, mime_type) = futures::executor::block_on(image_input.to_base64()).unwrap_or_default();
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": { "url": format!("data:{};base64,{}", mime_type, base64_data) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
}).collect::<Vec<_>>(),
|
||||||
|
"stream": false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(temp) = request.temperature {
|
||||||
|
body["temperature"] = serde_json::json!(temp);
|
||||||
|
}
|
||||||
|
if let Some(max_tokens) = request.max_tokens {
|
||||||
|
body["max_tokens"] = serde_json::json!(max_tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self.client.post(format!("{}/chat/completions", self.config.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::ProviderError(e.to_string()))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(AppError::ProviderError(format!("DeepSeek API error: {}", error_text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp_json: Value = response.json().await.map_err(|e| AppError::ProviderError(e.to_string()))?;
|
||||||
|
|
||||||
|
let choice = resp_json["choices"].get(0).ok_or_else(|| AppError::ProviderError("No choices in response".to_string()))?;
|
||||||
|
let message = &choice["message"];
|
||||||
|
|
||||||
|
let content = message["content"].as_str().unwrap_or_default().to_string();
|
||||||
|
let reasoning_content = message["reasoning_content"].as_str().map(|s| s.to_string());
|
||||||
|
|
||||||
|
let usage = &resp_json["usage"];
|
||||||
|
let prompt_tokens = usage["prompt_tokens"].as_u64().unwrap_or(0) as u32;
|
||||||
|
let completion_tokens = usage["completion_tokens"].as_u64().unwrap_or(0) as u32;
|
||||||
|
let total_tokens = usage["total_tokens"].as_u64().unwrap_or(0) as u32;
|
||||||
|
|
||||||
|
Ok(ProviderResponse {
|
||||||
|
content,
|
||||||
|
reasoning_content,
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
total_tokens,
|
||||||
|
model: request.model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimate_tokens(&self, request: &UnifiedRequest) -> Result<u32> {
|
||||||
|
Ok(crate::utils::tokens::estimate_request_tokens(&request.model, request))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_cost(&self, model: &str, prompt_tokens: u32, completion_tokens: u32, registry: &crate::models::registry::ModelRegistry) -> f64 {
|
||||||
|
if let Some(metadata) = registry.find_model(model) {
|
||||||
|
if let Some(cost) = &metadata.cost {
|
||||||
|
return (prompt_tokens as f64 * cost.input / 1_000_000.0) +
|
||||||
|
(completion_tokens as f64 * cost.output / 1_000_000.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (prompt_rate, completion_rate) = self.pricing.iter()
|
||||||
|
.find(|p| model.contains(&p.model))
|
||||||
|
.map(|p| (p.prompt_tokens_per_million, p.completion_tokens_per_million))
|
||||||
|
.unwrap_or((0.14, 0.28));
|
||||||
|
|
||||||
|
(prompt_tokens as f64 * prompt_rate / 1_000_000.0) + (completion_tokens as f64 * completion_rate / 1_000_000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completion_stream(
|
||||||
|
&self,
|
||||||
|
request: UnifiedRequest,
|
||||||
|
) -> Result<BoxStream<'static, Result<ProviderStreamChunk, AppError>>, AppError> {
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"model": request.model,
|
||||||
|
"messages": request.messages.iter().map(|m| {
|
||||||
|
serde_json::json!({
|
||||||
|
"role": m.role,
|
||||||
|
"content": m.content.iter().map(|p| {
|
||||||
|
match p {
|
||||||
|
crate::models::ContentPart::Text { text } => serde_json::json!({ "type": "text", "text": text }),
|
||||||
|
crate::models::ContentPart::Image(_) => serde_json::json!({ "type": "text", "text": "[Image]" }),
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
}).collect::<Vec<_>>(),
|
||||||
|
"stream": true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(temp) = request.temperature {
|
||||||
|
body["temperature"] = serde_json::json!(temp);
|
||||||
|
}
|
||||||
|
if let Some(max_tokens) = request.max_tokens {
|
||||||
|
body["max_tokens"] = serde_json::json!(max_tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create eventsource stream
|
||||||
|
use reqwest_eventsource::{EventSource, Event};
|
||||||
|
let es = EventSource::new(self.client.post(format!("{}/chat/completions", self.config.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||||
|
.json(&body))
|
||||||
|
.map_err(|e| AppError::ProviderError(format!("Failed to create EventSource: {}", e)))?;
|
||||||
|
|
||||||
|
let model = request.model.clone();
|
||||||
|
|
||||||
|
let stream = async_stream::try_stream! {
|
||||||
|
let mut es = es;
|
||||||
|
while let Some(event) = es.next().await {
|
||||||
|
match event {
|
||||||
|
Ok(Event::Message(msg)) => {
|
||||||
|
if msg.data == "[DONE]" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk: Value = serde_json::from_str(&msg.data)
|
||||||
|
.map_err(|e| AppError::ProviderError(format!("Failed to parse stream chunk: {}", e)))?;
|
||||||
|
|
||||||
|
if let Some(choice) = chunk["choices"].get(0) {
|
||||||
|
let delta = &choice["delta"];
|
||||||
|
let content = delta["content"].as_str().unwrap_or_default().to_string();
|
||||||
|
let reasoning_content = delta["reasoning_content"].as_str().map(|s| s.to_string());
|
||||||
|
let finish_reason = choice["finish_reason"].as_str().map(|s| s.to_string());
|
||||||
|
|
||||||
|
yield ProviderStreamChunk {
|
||||||
|
content,
|
||||||
|
reasoning_content,
|
||||||
|
finish_reason,
|
||||||
|
model: model.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(e) => {
|
||||||
|
Err(AppError::ProviderError(format!("Stream error: {}", e)))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Box::pin(stream))
|
||||||
|
}
|
||||||
|
}
|
||||||
344
src/providers/gemini.rs
Normal file
344
src/providers/gemini.rs
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use futures::stream::BoxStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
models::UnifiedRequest,
|
||||||
|
errors::AppError,
|
||||||
|
config::AppConfig,
|
||||||
|
};
|
||||||
|
use super::{ProviderResponse, ProviderStreamChunk};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct GeminiRequest {
|
||||||
|
contents: Vec<GeminiContent>,
|
||||||
|
generation_config: Option<GeminiGenerationConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct GeminiContent {
|
||||||
|
parts: Vec<GeminiPart>,
|
||||||
|
role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct GeminiPart {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
text: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
inline_data: Option<GeminiInlineData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct GeminiInlineData {
|
||||||
|
mime_type: String,
|
||||||
|
data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct GeminiGenerationConfig {
|
||||||
|
temperature: Option<f64>,
|
||||||
|
max_output_tokens: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GeminiCandidate {
|
||||||
|
content: GeminiContent,
|
||||||
|
_finish_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GeminiUsageMetadata {
|
||||||
|
prompt_token_count: u32,
|
||||||
|
candidates_token_count: u32,
|
||||||
|
total_token_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GeminiResponse {
|
||||||
|
candidates: Vec<GeminiCandidate>,
|
||||||
|
usage_metadata: Option<GeminiUsageMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
pub struct GeminiProvider {
|
||||||
|
client: reqwest::Client,
|
||||||
|
config: crate::config::GeminiConfig,
|
||||||
|
api_key: String,
|
||||||
|
pricing: Vec<crate::config::ModelPricing>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GeminiProvider {
|
||||||
|
pub fn new(config: &crate::config::GeminiConfig, app_config: &AppConfig) -> Result<Self> {
|
||||||
|
let api_key = app_config.get_api_key("gemini")?;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
config: config.clone(),
|
||||||
|
api_key,
|
||||||
|
pricing: app_config.pricing.gemini.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl super::Provider for GeminiProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"gemini"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_model(&self, model: &str) -> bool {
|
||||||
|
model.starts_with("gemini-")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_multimodal(&self) -> bool {
|
||||||
|
true // Gemini supports vision
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completion(
|
||||||
|
&self,
|
||||||
|
request: UnifiedRequest,
|
||||||
|
) -> Result<ProviderResponse, AppError> {
|
||||||
|
// Convert UnifiedRequest to Gemini request
|
||||||
|
let mut contents = Vec::with_capacity(request.messages.len());
|
||||||
|
|
||||||
|
for msg in request.messages {
|
||||||
|
let mut parts = Vec::with_capacity(msg.content.len());
|
||||||
|
|
||||||
|
for part in msg.content {
|
||||||
|
match part {
|
||||||
|
crate::models::ContentPart::Text { text } => {
|
||||||
|
parts.push(GeminiPart {
|
||||||
|
text: Some(text),
|
||||||
|
inline_data: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
crate::models::ContentPart::Image(image_input) => {
|
||||||
|
let (base64_data, mime_type) = image_input.to_base64().await
|
||||||
|
.map_err(|e| AppError::ProviderError(format!("Failed to convert image: {}", e)))?;
|
||||||
|
|
||||||
|
parts.push(GeminiPart {
|
||||||
|
text: None,
|
||||||
|
inline_data: Some(GeminiInlineData {
|
||||||
|
mime_type,
|
||||||
|
data: base64_data,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map role: "user" -> "user", "assistant" -> "model", "system" -> "user"
|
||||||
|
let role = match msg.role.as_str() {
|
||||||
|
"assistant" => "model".to_string(),
|
||||||
|
_ => "user".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
contents.push(GeminiContent {
|
||||||
|
parts,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if contents.is_empty() {
|
||||||
|
return Err(AppError::ProviderError("No valid text messages to send".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build generation config
|
||||||
|
let generation_config = if request.temperature.is_some() || request.max_tokens.is_some() {
|
||||||
|
Some(GeminiGenerationConfig {
|
||||||
|
temperature: request.temperature,
|
||||||
|
max_output_tokens: request.max_tokens,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let gemini_request = GeminiRequest {
|
||||||
|
contents,
|
||||||
|
generation_config,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build URL
|
||||||
|
let url = format!("{}/models/{}:generateContent?key={}",
|
||||||
|
self.config.base_url,
|
||||||
|
request.model,
|
||||||
|
self.api_key
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send request
|
||||||
|
let response = self.client
|
||||||
|
.post(&url)
|
||||||
|
.json(&gemini_request)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::ProviderError(format!("HTTP request failed: {}", e)))?;
|
||||||
|
|
||||||
|
// Check status
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(AppError::ProviderError(format!("Gemini API error ({}): {}", status, error_text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let gemini_response: GeminiResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::ProviderError(format!("Failed to parse response: {}", e)))?;
|
||||||
|
|
||||||
|
// Extract content from first candidate
|
||||||
|
let content = gemini_response.candidates
|
||||||
|
.first()
|
||||||
|
.and_then(|c| c.content.parts.first())
|
||||||
|
.and_then(|p| p.text.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Extract token usage
|
||||||
|
let prompt_tokens = gemini_response.usage_metadata.as_ref().map(|u| u.prompt_token_count).unwrap_or(0);
|
||||||
|
let completion_tokens = gemini_response.usage_metadata.as_ref().map(|u| u.candidates_token_count).unwrap_or(0);
|
||||||
|
let total_tokens = gemini_response.usage_metadata.as_ref().map(|u| u.total_token_count).unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(ProviderResponse {
|
||||||
|
content,
|
||||||
|
reasoning_content: None, // Gemini doesn't use this field name
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
total_tokens,
|
||||||
|
model: request.model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimate_tokens(&self, request: &UnifiedRequest) -> Result<u32> {
|
||||||
|
Ok(crate::utils::tokens::estimate_request_tokens(&request.model, request))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_cost(&self, model: &str, prompt_tokens: u32, completion_tokens: u32, registry: &crate::models::registry::ModelRegistry) -> f64 {
|
||||||
|
if let Some(metadata) = registry.find_model(model) {
|
||||||
|
if let Some(cost) = &metadata.cost {
|
||||||
|
return (prompt_tokens as f64 * cost.input / 1_000_000.0) +
|
||||||
|
(completion_tokens as f64 * cost.output / 1_000_000.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (prompt_rate, completion_rate) = self.pricing.iter()
|
||||||
|
.find(|p| model.contains(&p.model))
|
||||||
|
.map(|p| (p.prompt_tokens_per_million, p.completion_tokens_per_million))
|
||||||
|
.unwrap_or((0.075, 0.30)); // Default to Gemini 2.0 Flash price if not found
|
||||||
|
|
||||||
|
(prompt_tokens as f64 * prompt_rate / 1_000_000.0) + (completion_tokens as f64 * completion_rate / 1_000_000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completion_stream(
|
||||||
|
&self,
|
||||||
|
request: UnifiedRequest,
|
||||||
|
) -> Result<BoxStream<'static, Result<ProviderStreamChunk, AppError>>, AppError> {
|
||||||
|
// Convert UnifiedRequest to Gemini request
|
||||||
|
let mut contents = Vec::with_capacity(request.messages.len());
|
||||||
|
|
||||||
|
for msg in request.messages {
|
||||||
|
let mut parts = Vec::with_capacity(msg.content.len());
|
||||||
|
|
||||||
|
for part in msg.content {
|
||||||
|
match part {
|
||||||
|
crate::models::ContentPart::Text { text } => {
|
||||||
|
parts.push(GeminiPart {
|
||||||
|
text: Some(text),
|
||||||
|
inline_data: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
crate::models::ContentPart::Image(image_input) => {
|
||||||
|
let (base64_data, mime_type) = image_input.to_base64().await
|
||||||
|
.map_err(|e| AppError::ProviderError(format!("Failed to convert image: {}", e)))?;
|
||||||
|
|
||||||
|
parts.push(GeminiPart {
|
||||||
|
text: None,
|
||||||
|
inline_data: Some(GeminiInlineData {
|
||||||
|
mime_type,
|
||||||
|
data: base64_data,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map role
|
||||||
|
let role = match msg.role.as_str() {
|
||||||
|
"assistant" => "model".to_string(),
|
||||||
|
_ => "user".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
contents.push(GeminiContent {
|
||||||
|
parts,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build generation config
|
||||||
|
let generation_config = if request.temperature.is_some() || request.max_tokens.is_some() {
|
||||||
|
Some(GeminiGenerationConfig {
|
||||||
|
temperature: request.temperature,
|
||||||
|
max_output_tokens: request.max_tokens,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let gemini_request = GeminiRequest {
|
||||||
|
contents,
|
||||||
|
generation_config,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build URL for streaming
|
||||||
|
let url = format!("{}/models/{}:streamGenerateContent?alt=sse&key={}",
|
||||||
|
self.config.base_url,
|
||||||
|
request.model,
|
||||||
|
self.api_key
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create eventsource stream
|
||||||
|
use reqwest_eventsource::{EventSource, Event};
|
||||||
|
use futures::StreamExt;
|
||||||
|
|
||||||
|
let es = EventSource::new(self.client.post(&url).json(&gemini_request))
|
||||||
|
.map_err(|e| AppError::ProviderError(format!("Failed to create EventSource: {}", e)))?;
|
||||||
|
|
||||||
|
let model = request.model.clone();
|
||||||
|
|
||||||
|
let stream = async_stream::try_stream! {
|
||||||
|
let mut es = es;
|
||||||
|
while let Some(event) = es.next().await {
|
||||||
|
match event {
|
||||||
|
Ok(Event::Message(msg)) => {
|
||||||
|
let gemini_response: GeminiResponse = serde_json::from_str(&msg.data)
|
||||||
|
.map_err(|e| AppError::ProviderError(format!("Failed to parse stream chunk: {}", e)))?;
|
||||||
|
|
||||||
|
if let Some(candidate) = gemini_response.candidates.first() {
|
||||||
|
let content = candidate.content.parts.first()
|
||||||
|
.and_then(|p| p.text.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
yield ProviderStreamChunk {
|
||||||
|
content,
|
||||||
|
reasoning_content: None,
|
||||||
|
finish_reason: None, // Will be set in the last chunk
|
||||||
|
model: model.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(e) => {
|
||||||
|
Err(AppError::ProviderError(format!("Stream error: {}", e)))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Box::pin(stream))
|
||||||
|
}
|
||||||
|
}
|
||||||
213
src/providers/grok.rs
Normal file
213
src/providers/grok.rs
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use anyhow::Result;
|
||||||
|
use futures::stream::{BoxStream, StreamExt};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
models::UnifiedRequest,
|
||||||
|
errors::AppError,
|
||||||
|
config::AppConfig,
|
||||||
|
};
|
||||||
|
use super::{ProviderResponse, ProviderStreamChunk};
|
||||||
|
|
||||||
|
pub struct GrokProvider {
|
||||||
|
client: reqwest::Client,
|
||||||
|
_config: crate::config::GrokConfig,
|
||||||
|
api_key: String,
|
||||||
|
pricing: Vec<crate::config::ModelPricing>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GrokProvider {
|
||||||
|
pub fn new(config: &crate::config::GrokConfig, app_config: &AppConfig) -> Result<Self> {
|
||||||
|
let api_key = app_config.get_api_key("grok")?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
_config: config.clone(),
|
||||||
|
api_key,
|
||||||
|
pricing: app_config.pricing.grok.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl super::Provider for GrokProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"grok"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_model(&self, model: &str) -> bool {
|
||||||
|
model.starts_with("grok-")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_multimodal(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completion(
|
||||||
|
&self,
|
||||||
|
request: UnifiedRequest,
|
||||||
|
) -> Result<ProviderResponse, AppError> {
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"model": request.model,
|
||||||
|
"messages": request.messages.iter().map(|m| {
|
||||||
|
serde_json::json!({
|
||||||
|
"role": m.role,
|
||||||
|
"content": m.content.iter().map(|p| {
|
||||||
|
match p {
|
||||||
|
crate::models::ContentPart::Text { text } => serde_json::json!({ "type": "text", "text": text }),
|
||||||
|
crate::models::ContentPart::Image(image_input) => {
|
||||||
|
let (base64_data, mime_type) = futures::executor::block_on(image_input.to_base64()).unwrap_or_default();
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": { "url": format!("data:{};base64,{}", mime_type, base64_data) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
}).collect::<Vec<_>>(),
|
||||||
|
"stream": false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(temp) = request.temperature {
|
||||||
|
body["temperature"] = serde_json::json!(temp);
|
||||||
|
}
|
||||||
|
if let Some(max_tokens) = request.max_tokens {
|
||||||
|
body["max_tokens"] = serde_json::json!(max_tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self.client.post(format!("{}/chat/completions", self._config.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::ProviderError(e.to_string()))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(AppError::ProviderError(format!("Grok API error: {}", error_text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp_json: Value = response.json().await.map_err(|e| AppError::ProviderError(e.to_string()))?;
|
||||||
|
|
||||||
|
let choice = resp_json["choices"].get(0).ok_or_else(|| AppError::ProviderError("No choices in response".to_string()))?;
|
||||||
|
let message = &choice["message"];
|
||||||
|
|
||||||
|
let content = message["content"].as_str().unwrap_or_default().to_string();
|
||||||
|
let reasoning_content = message["reasoning_content"].as_str().map(|s| s.to_string());
|
||||||
|
|
||||||
|
let usage = &resp_json["usage"];
|
||||||
|
let prompt_tokens = usage["prompt_tokens"].as_u64().unwrap_or(0) as u32;
|
||||||
|
let completion_tokens = usage["completion_tokens"].as_u64().unwrap_or(0) as u32;
|
||||||
|
let total_tokens = usage["total_tokens"].as_u64().unwrap_or(0) as u32;
|
||||||
|
|
||||||
|
Ok(ProviderResponse {
|
||||||
|
content,
|
||||||
|
reasoning_content,
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
total_tokens,
|
||||||
|
model: request.model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimate_tokens(&self, request: &UnifiedRequest) -> Result<u32> {
|
||||||
|
Ok(crate::utils::tokens::estimate_request_tokens(&request.model, request))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_cost(&self, model: &str, prompt_tokens: u32, completion_tokens: u32, registry: &crate::models::registry::ModelRegistry) -> f64 {
|
||||||
|
if let Some(metadata) = registry.find_model(model) {
|
||||||
|
if let Some(cost) = &metadata.cost {
|
||||||
|
return (prompt_tokens as f64 * cost.input / 1_000_000.0) +
|
||||||
|
(completion_tokens as f64 * cost.output / 1_000_000.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (prompt_rate, completion_rate) = self.pricing.iter()
|
||||||
|
.find(|p| model.contains(&p.model))
|
||||||
|
.map(|p| (p.prompt_tokens_per_million, p.completion_tokens_per_million))
|
||||||
|
.unwrap_or((5.0, 15.0));
|
||||||
|
|
||||||
|
(prompt_tokens as f64 * prompt_rate / 1_000_000.0) + (completion_tokens as f64 * completion_rate / 1_000_000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completion_stream(
|
||||||
|
&self,
|
||||||
|
request: UnifiedRequest,
|
||||||
|
) -> Result<BoxStream<'static, Result<ProviderStreamChunk, AppError>>, AppError> {
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"model": request.model,
|
||||||
|
"messages": request.messages.iter().map(|m| {
|
||||||
|
serde_json::json!({
|
||||||
|
"role": m.role,
|
||||||
|
"content": m.content.iter().map(|p| {
|
||||||
|
match p {
|
||||||
|
crate::models::ContentPart::Text { text } => serde_json::json!({ "type": "text", "text": text }),
|
||||||
|
crate::models::ContentPart::Image(image_input) => {
|
||||||
|
let (base64_data, mime_type) = futures::executor::block_on(image_input.to_base64()).unwrap_or_default();
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": { "url": format!("data:{};base64,{}", mime_type, base64_data) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
}).collect::<Vec<_>>(),
|
||||||
|
"stream": true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(temp) = request.temperature {
|
||||||
|
body["temperature"] = serde_json::json!(temp);
|
||||||
|
}
|
||||||
|
if let Some(max_tokens) = request.max_tokens {
|
||||||
|
body["max_tokens"] = serde_json::json!(max_tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create eventsource stream
|
||||||
|
use reqwest_eventsource::{EventSource, Event};
|
||||||
|
let es = EventSource::new(self.client.post(format!("{}/chat/completions", self._config.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||||
|
.json(&body))
|
||||||
|
.map_err(|e| AppError::ProviderError(format!("Failed to create EventSource: {}", e)))?;
|
||||||
|
|
||||||
|
let model = request.model.clone();
|
||||||
|
|
||||||
|
let stream = async_stream::try_stream! {
|
||||||
|
let mut es = es;
|
||||||
|
while let Some(event) = es.next().await {
|
||||||
|
match event {
|
||||||
|
Ok(Event::Message(msg)) => {
|
||||||
|
if msg.data == "[DONE]" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk: Value = serde_json::from_str(&msg.data)
|
||||||
|
.map_err(|e| AppError::ProviderError(format!("Failed to parse stream chunk: {}", e)))?;
|
||||||
|
|
||||||
|
if let Some(choice) = chunk["choices"].get(0) {
|
||||||
|
let delta = &choice["delta"];
|
||||||
|
let content = delta["content"].as_str().unwrap_or_default().to_string();
|
||||||
|
let reasoning_content = delta["reasoning_content"].as_str().map(|s| s.to_string());
|
||||||
|
let finish_reason = choice["finish_reason"].as_str().map(|s| s.to_string());
|
||||||
|
|
||||||
|
yield ProviderStreamChunk {
|
||||||
|
content,
|
||||||
|
reasoning_content,
|
||||||
|
finish_reason,
|
||||||
|
model: model.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(e) => {
|
||||||
|
Err(AppError::ProviderError(format!("Stream error: {}", e)))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Box::pin(stream))
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/providers/mod.rs
Normal file
141
src/providers/mod.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use futures::stream::BoxStream;
|
||||||
|
|
||||||
|
use crate::models::UnifiedRequest;
|
||||||
|
use crate::errors::AppError;
|
||||||
|
|
||||||
|
pub mod openai;
|
||||||
|
pub mod gemini;
|
||||||
|
pub mod deepseek;
|
||||||
|
pub mod grok;
|
||||||
|
pub mod ollama;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Provider: Send + Sync {
|
||||||
|
/// Get provider name (e.g., "openai", "gemini")
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
|
||||||
|
/// Check if provider supports a specific model
|
||||||
|
fn supports_model(&self, model: &str) -> bool;
|
||||||
|
|
||||||
|
/// Check if provider supports multimodal (images, etc.)
|
||||||
|
fn supports_multimodal(&self) -> bool;
|
||||||
|
|
||||||
|
/// Process a chat completion request
|
||||||
|
async fn chat_completion(
|
||||||
|
&self,
|
||||||
|
request: UnifiedRequest,
|
||||||
|
) -> Result<ProviderResponse, AppError>;
|
||||||
|
|
||||||
|
/// Process a streaming chat completion request
|
||||||
|
async fn chat_completion_stream(
|
||||||
|
&self,
|
||||||
|
request: UnifiedRequest,
|
||||||
|
) -> Result<BoxStream<'static, Result<ProviderStreamChunk, AppError>>, AppError>;
|
||||||
|
|
||||||
|
/// Estimate token count for a request (for cost calculation)
|
||||||
|
fn estimate_tokens(&self, request: &UnifiedRequest) -> Result<u32>;
|
||||||
|
|
||||||
|
/// Calculate cost based on token usage and model using the registry
|
||||||
|
fn calculate_cost(&self, model: &str, prompt_tokens: u32, completion_tokens: u32, registry: &crate::models::registry::ModelRegistry) -> f64;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProviderResponse {
|
||||||
|
pub content: String,
|
||||||
|
pub reasoning_content: Option<String>,
|
||||||
|
pub prompt_tokens: u32,
|
||||||
|
pub completion_tokens: u32,
|
||||||
|
pub total_tokens: u32,
|
||||||
|
pub model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProviderStreamChunk {
|
||||||
|
pub content: String,
|
||||||
|
pub reasoning_content: Option<String>,
|
||||||
|
pub finish_reason: Option<String>,
|
||||||
|
pub model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ProviderManager {
|
||||||
|
providers: Vec<Arc<dyn Provider>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
providers: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_provider(&mut self, provider: Arc<dyn Provider>) {
|
||||||
|
self.providers.push(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_provider_for_model(&self, model: &str) -> Option<Arc<dyn Provider>> {
|
||||||
|
self.providers.iter()
|
||||||
|
.find(|p| p.supports_model(model))
|
||||||
|
.map(|p| Arc::clone(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_provider(&self, name: &str) -> Option<Arc<dyn Provider>> {
|
||||||
|
self.providers.iter()
|
||||||
|
.find(|p| p.name() == name)
|
||||||
|
.map(|p| Arc::clone(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create placeholder provider implementations
|
||||||
|
pub mod placeholder {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub struct PlaceholderProvider {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaceholderProvider {
|
||||||
|
pub fn new(name: &str) -> Self {
|
||||||
|
Self { name: name.to_string() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Provider for PlaceholderProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_model(&self, _model: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_multimodal(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completion_stream(
|
||||||
|
&self,
|
||||||
|
_request: UnifiedRequest,
|
||||||
|
) -> Result<BoxStream<'static, Result<ProviderStreamChunk, AppError>>, AppError> {
|
||||||
|
Err(AppError::ProviderError("Streaming not supported for placeholder provider".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completion(
|
||||||
|
&self,
|
||||||
|
_request: UnifiedRequest,
|
||||||
|
) -> Result<ProviderResponse, AppError> {
|
||||||
|
Err(AppError::ProviderError(format!("Provider {} not implemented", self.name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimate_tokens(&self, _request: &UnifiedRequest) -> Result<u32> {
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_cost(&self, _model: &str, _prompt_tokens: u32, _completion_tokens: u32, _registry: &crate::models::registry::ModelRegistry) -> f64 {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/providers/ollama.rs
Normal file
205
src/providers/ollama.rs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use anyhow::Result;
|
||||||
|
use futures::stream::{BoxStream, StreamExt};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
models::UnifiedRequest,
|
||||||
|
errors::AppError,
|
||||||
|
config::AppConfig,
|
||||||
|
};
|
||||||
|
use super::{ProviderResponse, ProviderStreamChunk};
|
||||||
|
|
||||||
|
pub struct OllamaProvider {
|
||||||
|
client: reqwest::Client,
|
||||||
|
_config: crate::config::OllamaConfig,
|
||||||
|
pricing: Vec<crate::config::ModelPricing>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OllamaProvider {
|
||||||
|
pub fn new(config: &crate::config::OllamaConfig, app_config: &AppConfig) -> Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
_config: config.clone(),
|
||||||
|
pricing: app_config.pricing.ollama.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl super::Provider for OllamaProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"ollama"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_model(&self, model: &str) -> bool {
|
||||||
|
self._config.models.iter().any(|m| m == model) || model.starts_with("ollama/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_multimodal(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completion(
|
||||||
|
&self,
|
||||||
|
request: UnifiedRequest,
|
||||||
|
) -> Result<ProviderResponse, AppError> {
|
||||||
|
let model = request.model.strip_prefix("ollama/").unwrap_or(&request.model).to_string();
|
||||||
|
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"model": model,
|
||||||
|
"messages": request.messages.iter().map(|m| {
|
||||||
|
serde_json::json!({
|
||||||
|
"role": m.role,
|
||||||
|
"content": m.content.iter().map(|p| {
|
||||||
|
match p {
|
||||||
|
crate::models::ContentPart::Text { text } => serde_json::json!({ "type": "text", "text": text }),
|
||||||
|
crate::models::ContentPart::Image(image_input) => {
|
||||||
|
let (base64_data, mime_type) = futures::executor::block_on(image_input.to_base64()).unwrap_or_default();
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": { "url": format!("data:{};base64,{}", mime_type, base64_data) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
}).collect::<Vec<_>>(),
|
||||||
|
"stream": false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(temp) = request.temperature {
|
||||||
|
body["temperature"] = serde_json::json!(temp);
|
||||||
|
}
|
||||||
|
if let Some(max_tokens) = request.max_tokens {
|
||||||
|
body["max_tokens"] = serde_json::json!(max_tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self.client.post(format!("{}/chat/completions", self._config.base_url))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::ProviderError(e.to_string()))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(AppError::ProviderError(format!("Ollama API error: {}", error_text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp_json: Value = response.json().await.map_err(|e| AppError::ProviderError(e.to_string()))?;
|
||||||
|
|
||||||
|
let choice = resp_json["choices"].get(0).ok_or_else(|| AppError::ProviderError("No choices in response".to_string()))?;
|
||||||
|
let message = &choice["message"];
|
||||||
|
|
||||||
|
let content = message["content"].as_str().unwrap_or_default().to_string();
|
||||||
|
let reasoning_content = message["reasoning_content"].as_str().or_else(|| message["thought"].as_str()).map(|s| s.to_string());
|
||||||
|
|
||||||
|
let usage = &resp_json["usage"];
|
||||||
|
let prompt_tokens = usage["prompt_tokens"].as_u64().unwrap_or(0) as u32;
|
||||||
|
let completion_tokens = usage["completion_tokens"].as_u64().unwrap_or(0) as u32;
|
||||||
|
let total_tokens = usage["total_tokens"].as_u64().unwrap_or(0) as u32;
|
||||||
|
|
||||||
|
Ok(ProviderResponse {
|
||||||
|
content,
|
||||||
|
reasoning_content,
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
total_tokens,
|
||||||
|
model: request.model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimate_tokens(&self, request: &UnifiedRequest) -> Result<u32> {
|
||||||
|
Ok(crate::utils::tokens::estimate_request_tokens(&request.model, request))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_cost(&self, model: &str, prompt_tokens: u32, completion_tokens: u32, registry: &crate::models::registry::ModelRegistry) -> f64 {
|
||||||
|
if let Some(metadata) = registry.find_model(model) {
|
||||||
|
if let Some(cost) = &metadata.cost {
|
||||||
|
return (prompt_tokens as f64 * cost.input / 1_000_000.0) +
|
||||||
|
(completion_tokens as f64 * cost.output / 1_000_000.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (prompt_rate, completion_rate) = self.pricing.iter()
|
||||||
|
.find(|p| model.contains(&p.model))
|
||||||
|
.map(|p| (p.prompt_tokens_per_million, p.completion_tokens_per_million))
|
||||||
|
.unwrap_or((0.0, 0.0));
|
||||||
|
|
||||||
|
(prompt_tokens as f64 * prompt_rate / 1_000_000.0) + (completion_tokens as f64 * completion_rate / 1_000_000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completion_stream(
|
||||||
|
&self,
|
||||||
|
request: UnifiedRequest,
|
||||||
|
) -> Result<BoxStream<'static, Result<ProviderStreamChunk, AppError>>, AppError> {
|
||||||
|
let model = request.model.strip_prefix("ollama/").unwrap_or(&request.model).to_string();
|
||||||
|
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"model": model,
|
||||||
|
"messages": request.messages.iter().map(|m| {
|
||||||
|
serde_json::json!({
|
||||||
|
"role": m.role,
|
||||||
|
"content": m.content.iter().map(|p| {
|
||||||
|
match p {
|
||||||
|
crate::models::ContentPart::Text { text } => serde_json::json!({ "type": "text", "text": text }),
|
||||||
|
crate::models::ContentPart::Image(_) => serde_json::json!({ "type": "text", "text": "[Image]" }),
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
}).collect::<Vec<_>>(),
|
||||||
|
"stream": true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(temp) = request.temperature {
|
||||||
|
body["temperature"] = serde_json::json!(temp);
|
||||||
|
}
|
||||||
|
if let Some(max_tokens) = request.max_tokens {
|
||||||
|
body["max_tokens"] = serde_json::json!(max_tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create eventsource stream
|
||||||
|
use reqwest_eventsource::{EventSource, Event};
|
||||||
|
let es = EventSource::new(self.client.post(format!("{}/chat/completions", self._config.base_url))
|
||||||
|
.json(&body))
|
||||||
|
.map_err(|e| AppError::ProviderError(format!("Failed to create EventSource: {}", e)))?;
|
||||||
|
|
||||||
|
let model_name = request.model.clone();
|
||||||
|
|
||||||
|
let stream = async_stream::try_stream! {
|
||||||
|
let mut es = es;
|
||||||
|
while let Some(event) = es.next().await {
|
||||||
|
match event {
|
||||||
|
Ok(Event::Message(msg)) => {
|
||||||
|
if msg.data == "[DONE]" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk: Value = serde_json::from_str(&msg.data)
|
||||||
|
.map_err(|e| AppError::ProviderError(format!("Failed to parse stream chunk: {}", e)))?;
|
||||||
|
|
||||||
|
if let Some(choice) = chunk["choices"].get(0) {
|
||||||
|
let delta = &choice["delta"];
|
||||||
|
let content = delta["content"].as_str().unwrap_or_default().to_string();
|
||||||
|
let reasoning_content = delta["reasoning_content"].as_str().or_else(|| delta["thought"].as_str()).map(|s| s.to_string());
|
||||||
|
let finish_reason = choice["finish_reason"].as_str().map(|s| s.to_string());
|
||||||
|
|
||||||
|
yield ProviderStreamChunk {
|
||||||
|
content,
|
||||||
|
reasoning_content,
|
||||||
|
finish_reason,
|
||||||
|
model: model_name.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(e) => {
|
||||||
|
Err(AppError::ProviderError(format!("Stream error: {}", e)))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Box::pin(stream))
|
||||||
|
}
|
||||||
|
}
|
||||||
213
src/providers/openai.rs
Normal file
213
src/providers/openai.rs
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use anyhow::Result;
|
||||||
|
use futures::stream::{BoxStream, StreamExt};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
models::UnifiedRequest,
|
||||||
|
errors::AppError,
|
||||||
|
config::AppConfig,
|
||||||
|
};
|
||||||
|
use super::{ProviderResponse, ProviderStreamChunk};
|
||||||
|
|
||||||
|
pub struct OpenAIProvider {
|
||||||
|
client: reqwest::Client,
|
||||||
|
_config: crate::config::OpenAIConfig,
|
||||||
|
api_key: String,
|
||||||
|
pricing: Vec<crate::config::ModelPricing>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenAIProvider {
|
||||||
|
pub fn new(config: &crate::config::OpenAIConfig, app_config: &AppConfig) -> Result<Self> {
|
||||||
|
let api_key = app_config.get_api_key("openai")?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
_config: config.clone(),
|
||||||
|
api_key,
|
||||||
|
pricing: app_config.pricing.openai.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl super::Provider for OpenAIProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"openai"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_model(&self, model: &str) -> bool {
|
||||||
|
model.starts_with("gpt-") || model.starts_with("o1-") || model.starts_with("o3-")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_multimodal(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completion(
|
||||||
|
&self,
|
||||||
|
request: UnifiedRequest,
|
||||||
|
) -> Result<ProviderResponse, AppError> {
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"model": request.model,
|
||||||
|
"messages": request.messages.iter().map(|m| {
|
||||||
|
serde_json::json!({
|
||||||
|
"role": m.role,
|
||||||
|
"content": m.content.iter().map(|p| {
|
||||||
|
match p {
|
||||||
|
crate::models::ContentPart::Text { text } => serde_json::json!({ "type": "text", "text": text }),
|
||||||
|
crate::models::ContentPart::Image(image_input) => {
|
||||||
|
let (base64_data, mime_type) = futures::executor::block_on(image_input.to_base64()).unwrap_or_default();
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": { "url": format!("data:{};base64,{}", mime_type, base64_data) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
}).collect::<Vec<_>>(),
|
||||||
|
"stream": false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(temp) = request.temperature {
|
||||||
|
body["temperature"] = serde_json::json!(temp);
|
||||||
|
}
|
||||||
|
if let Some(max_tokens) = request.max_tokens {
|
||||||
|
body["max_tokens"] = serde_json::json!(max_tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self.client.post(format!("{}/chat/completions", self._config.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::ProviderError(e.to_string()))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(AppError::ProviderError(format!("OpenAI API error: {}", error_text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp_json: Value = response.json().await.map_err(|e| AppError::ProviderError(e.to_string()))?;
|
||||||
|
|
||||||
|
let choice = resp_json["choices"].get(0).ok_or_else(|| AppError::ProviderError("No choices in response".to_string()))?;
|
||||||
|
let message = &choice["message"];
|
||||||
|
|
||||||
|
let content = message["content"].as_str().unwrap_or_default().to_string();
|
||||||
|
let reasoning_content = message["reasoning_content"].as_str().map(|s| s.to_string());
|
||||||
|
|
||||||
|
let usage = &resp_json["usage"];
|
||||||
|
let prompt_tokens = usage["prompt_tokens"].as_u64().unwrap_or(0) as u32;
|
||||||
|
let completion_tokens = usage["completion_tokens"].as_u64().unwrap_or(0) as u32;
|
||||||
|
let total_tokens = usage["total_tokens"].as_u64().unwrap_or(0) as u32;
|
||||||
|
|
||||||
|
Ok(ProviderResponse {
|
||||||
|
content,
|
||||||
|
reasoning_content,
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
total_tokens,
|
||||||
|
model: request.model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimate_tokens(&self, request: &UnifiedRequest) -> Result<u32> {
|
||||||
|
Ok(crate::utils::tokens::estimate_request_tokens(&request.model, request))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_cost(&self, model: &str, prompt_tokens: u32, completion_tokens: u32, registry: &crate::models::registry::ModelRegistry) -> f64 {
|
||||||
|
if let Some(metadata) = registry.find_model(model) {
|
||||||
|
if let Some(cost) = &metadata.cost {
|
||||||
|
return (prompt_tokens as f64 * cost.input / 1_000_000.0) +
|
||||||
|
(completion_tokens as f64 * cost.output / 1_000_000.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (prompt_rate, completion_rate) = self.pricing.iter()
|
||||||
|
.find(|p| model.contains(&p.model))
|
||||||
|
.map(|p| (p.prompt_tokens_per_million, p.completion_tokens_per_million))
|
||||||
|
.unwrap_or((0.15, 0.60));
|
||||||
|
|
||||||
|
(prompt_tokens as f64 * prompt_rate / 1_000_000.0) + (completion_tokens as f64 * completion_rate / 1_000_000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completion_stream(
|
||||||
|
&self,
|
||||||
|
request: UnifiedRequest,
|
||||||
|
) -> Result<BoxStream<'static, Result<ProviderStreamChunk, AppError>>, AppError> {
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"model": request.model,
|
||||||
|
"messages": request.messages.iter().map(|m| {
|
||||||
|
serde_json::json!({
|
||||||
|
"role": m.role,
|
||||||
|
"content": m.content.iter().map(|p| {
|
||||||
|
match p {
|
||||||
|
crate::models::ContentPart::Text { text } => serde_json::json!({ "type": "text", "text": text }),
|
||||||
|
crate::models::ContentPart::Image(image_input) => {
|
||||||
|
let (base64_data, mime_type) = futures::executor::block_on(image_input.to_base64()).unwrap_or_default();
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": { "url": format!("data:{};base64,{}", mime_type, base64_data) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
}).collect::<Vec<_>>(),
|
||||||
|
"stream": true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(temp) = request.temperature {
|
||||||
|
body["temperature"] = serde_json::json!(temp);
|
||||||
|
}
|
||||||
|
if let Some(max_tokens) = request.max_tokens {
|
||||||
|
body["max_tokens"] = serde_json::json!(max_tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create eventsource stream
|
||||||
|
use reqwest_eventsource::{EventSource, Event};
|
||||||
|
let es = EventSource::new(self.client.post(format!("{}/chat/completions", self._config.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||||
|
.json(&body))
|
||||||
|
.map_err(|e| AppError::ProviderError(format!("Failed to create EventSource: {}", e)))?;
|
||||||
|
|
||||||
|
let model = request.model.clone();
|
||||||
|
|
||||||
|
let stream = async_stream::try_stream! {
|
||||||
|
let mut es = es;
|
||||||
|
while let Some(event) = es.next().await {
|
||||||
|
match event {
|
||||||
|
Ok(Event::Message(msg)) => {
|
||||||
|
if msg.data == "[DONE]" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk: Value = serde_json::from_str(&msg.data)
|
||||||
|
.map_err(|e| AppError::ProviderError(format!("Failed to parse stream chunk: {}", e)))?;
|
||||||
|
|
||||||
|
if let Some(choice) = chunk["choices"].get(0) {
|
||||||
|
let delta = &choice["delta"];
|
||||||
|
let content = delta["content"].as_str().unwrap_or_default().to_string();
|
||||||
|
let reasoning_content = delta["reasoning_content"].as_str().map(|s| s.to_string());
|
||||||
|
let finish_reason = choice["finish_reason"].as_str().map(|s| s.to_string());
|
||||||
|
|
||||||
|
yield ProviderStreamChunk {
|
||||||
|
content,
|
||||||
|
reasoning_content,
|
||||||
|
finish_reason,
|
||||||
|
model: model.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(e) => {
|
||||||
|
Err(AppError::ProviderError(format!("Stream error: {}", e)))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Box::pin(stream))
|
||||||
|
}
|
||||||
|
}
|
||||||
359
src/rate_limiting/mod.rs
Normal file
359
src/rate_limiting/mod.rs
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
//! Rate limiting and circuit breaking for LLM proxy
|
||||||
|
//!
|
||||||
|
//! This module provides:
|
||||||
|
//! 1. Per-client rate limiting using governor crate
|
||||||
|
//! 2. Provider circuit breaking to handle API failures
|
||||||
|
//! 3. Global rate limiting for overall system protection
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// Rate limiter configuration
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RateLimiterConfig {
|
||||||
|
/// Requests per minute per client
|
||||||
|
pub requests_per_minute: u32,
|
||||||
|
/// Burst size (maximum burst capacity)
|
||||||
|
pub burst_size: u32,
|
||||||
|
/// Global requests per minute (across all clients)
|
||||||
|
pub global_requests_per_minute: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RateLimiterConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
requests_per_minute: 60, // 1 request per second per client
|
||||||
|
burst_size: 10, // Allow bursts of up to 10 requests
|
||||||
|
global_requests_per_minute: 600, // 10 requests per second globally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Circuit breaker state
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum CircuitState {
|
||||||
|
Closed, // Normal operation
|
||||||
|
Open, // Circuit is open, requests fail fast
|
||||||
|
HalfOpen, // Testing if service has recovered
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Circuit breaker configuration
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CircuitBreakerConfig {
|
||||||
|
/// Failure threshold to open circuit
|
||||||
|
pub failure_threshold: u32,
|
||||||
|
/// Time window for failure counting (seconds)
|
||||||
|
pub failure_window_secs: u64,
|
||||||
|
/// Time to wait before trying half-open state (seconds)
|
||||||
|
pub reset_timeout_secs: u64,
|
||||||
|
/// Success threshold to close circuit
|
||||||
|
pub success_threshold: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CircuitBreakerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
failure_threshold: 5, // 5 failures
|
||||||
|
failure_window_secs: 60, // within 60 seconds
|
||||||
|
reset_timeout_secs: 30, // wait 30 seconds before half-open
|
||||||
|
success_threshold: 3, // 3 successes to close circuit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple token bucket rate limiter for a single client
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TokenBucket {
|
||||||
|
tokens: f64,
|
||||||
|
capacity: f64,
|
||||||
|
refill_rate: f64, // tokens per second
|
||||||
|
last_refill: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenBucket {
|
||||||
|
fn new(capacity: f64, refill_rate: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
tokens: capacity,
|
||||||
|
capacity,
|
||||||
|
refill_rate,
|
||||||
|
last_refill: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refill(&mut self) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
|
||||||
|
let new_tokens = elapsed * self.refill_rate;
|
||||||
|
|
||||||
|
self.tokens = (self.tokens + new_tokens).min(self.capacity);
|
||||||
|
self.last_refill = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_acquire(&mut self, tokens: f64) -> bool {
|
||||||
|
self.refill();
|
||||||
|
|
||||||
|
if self.tokens >= tokens {
|
||||||
|
self.tokens -= tokens;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Circuit breaker for a provider
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ProviderCircuitBreaker {
|
||||||
|
state: CircuitState,
|
||||||
|
failure_count: u32,
|
||||||
|
success_count: u32,
|
||||||
|
last_failure_time: Option<std::time::Instant>,
|
||||||
|
last_state_change: std::time::Instant,
|
||||||
|
config: CircuitBreakerConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderCircuitBreaker {
|
||||||
|
pub fn new(config: CircuitBreakerConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
state: CircuitState::Closed,
|
||||||
|
failure_count: 0,
|
||||||
|
success_count: 0,
|
||||||
|
last_failure_time: None,
|
||||||
|
last_state_change: std::time::Instant::now(),
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if request is allowed
|
||||||
|
pub fn allow_request(&mut self) -> bool {
|
||||||
|
match self.state {
|
||||||
|
CircuitState::Closed => true,
|
||||||
|
CircuitState::Open => {
|
||||||
|
// Check if reset timeout has passed
|
||||||
|
let elapsed = self.last_state_change.elapsed();
|
||||||
|
if elapsed.as_secs() >= self.config.reset_timeout_secs {
|
||||||
|
self.state = CircuitState::HalfOpen;
|
||||||
|
self.last_state_change = std::time::Instant::now();
|
||||||
|
info!("Circuit breaker transitioning to half-open state");
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CircuitState::HalfOpen => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a successful request
|
||||||
|
pub fn record_success(&mut self) {
|
||||||
|
match self.state {
|
||||||
|
CircuitState::Closed => {
|
||||||
|
// Reset failure count on success
|
||||||
|
self.failure_count = 0;
|
||||||
|
self.last_failure_time = None;
|
||||||
|
}
|
||||||
|
CircuitState::HalfOpen => {
|
||||||
|
self.success_count += 1;
|
||||||
|
if self.success_count >= self.config.success_threshold {
|
||||||
|
self.state = CircuitState::Closed;
|
||||||
|
self.success_count = 0;
|
||||||
|
self.failure_count = 0;
|
||||||
|
self.last_state_change = std::time::Instant::now();
|
||||||
|
info!("Circuit breaker closed after successful requests");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CircuitState::Open => {
|
||||||
|
// Should not happen, but handle gracefully
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a failed request
|
||||||
|
pub fn record_failure(&mut self) {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
|
||||||
|
// Check if failure window has expired
|
||||||
|
if let Some(last_failure) = self.last_failure_time {
|
||||||
|
if now.duration_since(last_failure).as_secs() > self.config.failure_window_secs {
|
||||||
|
// Reset failure count if window expired
|
||||||
|
self.failure_count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.failure_count += 1;
|
||||||
|
self.last_failure_time = Some(now);
|
||||||
|
|
||||||
|
if self.failure_count >= self.config.failure_threshold && self.state == CircuitState::Closed {
|
||||||
|
self.state = CircuitState::Open;
|
||||||
|
self.last_state_change = now;
|
||||||
|
warn!("Circuit breaker opened due to {} failures", self.failure_count);
|
||||||
|
} else if self.state == CircuitState::HalfOpen {
|
||||||
|
// Failure in half-open state, go back to open
|
||||||
|
self.state = CircuitState::Open;
|
||||||
|
self.success_count = 0;
|
||||||
|
self.last_state_change = now;
|
||||||
|
warn!("Circuit breaker re-opened after failure in half-open state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current state
|
||||||
|
pub fn state(&self) -> CircuitState {
|
||||||
|
self.state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rate limiting and circuit breaking manager
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RateLimitManager {
|
||||||
|
client_buckets: Arc<RwLock<HashMap<String, TokenBucket>>>,
|
||||||
|
global_bucket: Arc<RwLock<TokenBucket>>,
|
||||||
|
circuit_breakers: Arc<RwLock<HashMap<String, ProviderCircuitBreaker>>>,
|
||||||
|
config: RateLimiterConfig,
|
||||||
|
circuit_config: CircuitBreakerConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimitManager {
|
||||||
|
pub fn new(config: RateLimiterConfig, circuit_config: CircuitBreakerConfig) -> Self {
|
||||||
|
// Convert requests per minute to tokens per second
|
||||||
|
let global_refill_rate = config.global_requests_per_minute as f64 / 60.0;
|
||||||
|
|
||||||
|
Self {
|
||||||
|
client_buckets: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
global_bucket: Arc::new(RwLock::new(TokenBucket::new(
|
||||||
|
config.burst_size as f64,
|
||||||
|
global_refill_rate,
|
||||||
|
))),
|
||||||
|
circuit_breakers: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
config,
|
||||||
|
circuit_config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a client request is allowed
|
||||||
|
pub async fn check_client_request(&self, client_id: &str) -> Result<bool> {
|
||||||
|
// Check global rate limit first (1 token per request)
|
||||||
|
{
|
||||||
|
let mut global_bucket = self.global_bucket.write().await;
|
||||||
|
if !global_bucket.try_acquire(1.0) {
|
||||||
|
warn!("Global rate limit exceeded");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check client-specific rate limit
|
||||||
|
let mut buckets = self.client_buckets.write().await;
|
||||||
|
let bucket = buckets
|
||||||
|
.entry(client_id.to_string())
|
||||||
|
.or_insert_with(|| {
|
||||||
|
TokenBucket::new(
|
||||||
|
self.config.burst_size as f64,
|
||||||
|
self.config.requests_per_minute as f64 / 60.0,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(bucket.try_acquire(1.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if provider requests are allowed (circuit breaker)
|
||||||
|
pub async fn check_provider_request(&self, provider_name: &str) -> Result<bool> {
|
||||||
|
let mut breakers = self.circuit_breakers.write().await;
|
||||||
|
let breaker = breakers
|
||||||
|
.entry(provider_name.to_string())
|
||||||
|
.or_insert_with(|| ProviderCircuitBreaker::new(self.circuit_config.clone()));
|
||||||
|
|
||||||
|
Ok(breaker.allow_request())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record provider success
|
||||||
|
pub async fn record_provider_success(&self, provider_name: &str) {
|
||||||
|
let mut breakers = self.circuit_breakers.write().await;
|
||||||
|
if let Some(breaker) = breakers.get_mut(provider_name) {
|
||||||
|
breaker.record_success();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record provider failure
|
||||||
|
pub async fn record_provider_failure(&self, provider_name: &str) {
|
||||||
|
let mut breakers = self.circuit_breakers.write().await;
|
||||||
|
let breaker = breakers
|
||||||
|
.entry(provider_name.to_string())
|
||||||
|
.or_insert_with(|| ProviderCircuitBreaker::new(self.circuit_config.clone()));
|
||||||
|
|
||||||
|
breaker.record_failure();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get provider circuit state
|
||||||
|
pub async fn get_provider_state(&self, provider_name: &str) -> CircuitState {
|
||||||
|
let breakers = self.circuit_breakers.read().await;
|
||||||
|
breakers
|
||||||
|
.get(provider_name)
|
||||||
|
.map(|b| b.state())
|
||||||
|
.unwrap_or(CircuitState::Closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Axum middleware for rate limiting
|
||||||
|
pub mod middleware {
|
||||||
|
use super::*;
|
||||||
|
use axum::{
|
||||||
|
extract::{Request, State},
|
||||||
|
middleware::Next,
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
use crate::errors::AppError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
|
||||||
|
/// Rate limiting middleware
|
||||||
|
pub async fn rate_limit_middleware(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
// Extract client ID from authentication header
|
||||||
|
let client_id = extract_client_id_from_request(&request);
|
||||||
|
|
||||||
|
// Check rate limits
|
||||||
|
if !state.rate_limit_manager.check_client_request(&client_id).await? {
|
||||||
|
return Err(AppError::RateLimitError(
|
||||||
|
"Rate limit exceeded".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(next.run(request).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract client ID from request (helper function)
|
||||||
|
fn extract_client_id_from_request(request: &Request) -> String {
|
||||||
|
// Try to extract from Authorization header
|
||||||
|
if let Some(auth_header) = request.headers().get("Authorization") {
|
||||||
|
if let Ok(auth_str) = auth_header.to_str() {
|
||||||
|
if auth_str.starts_with("Bearer ") {
|
||||||
|
let token = &auth_str[7..];
|
||||||
|
// Use token hash as client ID (same logic as auth module)
|
||||||
|
return format!("client_{}", &token[..8.min(token.len())]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to anonymous
|
||||||
|
"anonymous".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Circuit breaker middleware for provider requests
|
||||||
|
pub async fn circuit_breaker_middleware(
|
||||||
|
provider_name: &str,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if !state.rate_limit_manager.check_provider_request(provider_name).await? {
|
||||||
|
return Err(AppError::ProviderError(
|
||||||
|
format!("Provider {} is currently unavailable (circuit breaker open)", provider_name)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
224
src/server/mod.rs
Normal file
224
src/server/mod.rs
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
use uuid::Uuid;
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
routing::post,
|
||||||
|
Json, Router,
|
||||||
|
response::sse::{Event, Sse},
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use futures::stream::StreamExt;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::AuthenticatedClient,
|
||||||
|
errors::AppError,
|
||||||
|
models::{ChatCompletionRequest, ChatCompletionResponse, ChatCompletionStreamResponse, ChatStreamChoice, ChatStreamDelta, ChatMessage, ChatChoice, Usage},
|
||||||
|
state::AppState,
|
||||||
|
rate_limiting,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/v1/chat/completions", post(chat_completions))
|
||||||
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
rate_limiting::middleware::rate_limit_middleware,
|
||||||
|
))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completions(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthenticatedClient,
|
||||||
|
Json(request): Json<ChatCompletionRequest>,
|
||||||
|
) -> Result<axum::response::Response, AppError> {
|
||||||
|
let start_time = std::time::Instant::now();
|
||||||
|
let client_id = auth.client_id.clone();
|
||||||
|
let model = request.model.clone();
|
||||||
|
|
||||||
|
info!("Chat completion request from client {} for model {}", client_id, model);
|
||||||
|
|
||||||
|
// Find appropriate provider for the model
|
||||||
|
let provider = state.provider_manager.get_provider_for_model(&request.model)
|
||||||
|
.ok_or_else(|| AppError::ProviderError(format!("No provider found for model: {}", request.model)))?;
|
||||||
|
|
||||||
|
let provider_name = provider.name().to_string();
|
||||||
|
|
||||||
|
// Check circuit breaker for this provider
|
||||||
|
rate_limiting::middleware::circuit_breaker_middleware(&provider_name, &state).await?;
|
||||||
|
|
||||||
|
// Convert to unified request format
|
||||||
|
let mut unified_request = crate::models::UnifiedRequest::try_from(request)
|
||||||
|
.map_err(|e| AppError::ValidationError(e.to_string()))?;
|
||||||
|
|
||||||
|
// Set client_id from authentication
|
||||||
|
unified_request.client_id = client_id.clone();
|
||||||
|
|
||||||
|
// Hydrate images if present
|
||||||
|
if unified_request.has_images {
|
||||||
|
unified_request.hydrate_images().await
|
||||||
|
.map_err(|e| AppError::ValidationError(format!("Failed to process images: {}", e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if streaming is requested
|
||||||
|
if unified_request.stream {
|
||||||
|
// Estimate prompt tokens for logging later
|
||||||
|
let prompt_tokens = crate::utils::tokens::estimate_request_tokens(&model, &unified_request);
|
||||||
|
let has_images = unified_request.has_images;
|
||||||
|
|
||||||
|
// Handle streaming response
|
||||||
|
let stream_result = provider.chat_completion_stream(unified_request).await;
|
||||||
|
|
||||||
|
match stream_result {
|
||||||
|
Ok(stream) => {
|
||||||
|
// Record provider success
|
||||||
|
state.rate_limit_manager.record_provider_success(&provider_name).await;
|
||||||
|
|
||||||
|
// Wrap with AggregatingStream for token counting and database logging
|
||||||
|
let aggregating_stream = crate::utils::streaming::AggregatingStream::new(
|
||||||
|
stream,
|
||||||
|
client_id.clone(),
|
||||||
|
provider.clone(),
|
||||||
|
model.clone(),
|
||||||
|
prompt_tokens,
|
||||||
|
has_images,
|
||||||
|
state.request_logger.clone(),
|
||||||
|
state.client_manager.clone(),
|
||||||
|
state.model_registry.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create SSE stream from aggregating stream
|
||||||
|
let sse_stream = aggregating_stream.map(move |chunk_result| {
|
||||||
|
match chunk_result {
|
||||||
|
Ok(chunk) => {
|
||||||
|
// Convert provider chunk to OpenAI-compatible SSE event
|
||||||
|
let response = ChatCompletionStreamResponse {
|
||||||
|
id: format!("chatcmpl-{}", Uuid::new_v4()),
|
||||||
|
object: "chat.completion.chunk".to_string(),
|
||||||
|
created: chrono::Utc::now().timestamp() as u64,
|
||||||
|
model: chunk.model.clone(),
|
||||||
|
choices: vec![ChatStreamChoice {
|
||||||
|
index: 0,
|
||||||
|
delta: ChatStreamDelta {
|
||||||
|
role: None,
|
||||||
|
content: Some(chunk.content),
|
||||||
|
reasoning_content: chunk.reasoning_content,
|
||||||
|
},
|
||||||
|
finish_reason: chunk.finish_reason,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Event::default().json_data(response).unwrap())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Error in streaming response: {}", e);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Sse::new(sse_stream).into_response())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Record provider failure
|
||||||
|
state.rate_limit_manager.record_provider_failure(&provider_name).await;
|
||||||
|
|
||||||
|
// Log failed request
|
||||||
|
let duration = start_time.elapsed();
|
||||||
|
warn!("Streaming request failed after {:?}: {}", duration, e);
|
||||||
|
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle non-streaming response
|
||||||
|
let result = provider.chat_completion(unified_request).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(response) => {
|
||||||
|
// Record provider success
|
||||||
|
state.rate_limit_manager.record_provider_success(&provider_name).await;
|
||||||
|
|
||||||
|
let duration = start_time.elapsed();
|
||||||
|
let cost = provider.calculate_cost(&response.model, response.prompt_tokens, response.completion_tokens, &state.model_registry);
|
||||||
|
|
||||||
|
// Log request to database
|
||||||
|
state.request_logger.log_request(crate::logging::RequestLog {
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
client_id: client_id.clone(),
|
||||||
|
provider: provider_name.clone(),
|
||||||
|
model: response.model.clone(),
|
||||||
|
prompt_tokens: response.prompt_tokens,
|
||||||
|
completion_tokens: response.completion_tokens,
|
||||||
|
total_tokens: response.total_tokens,
|
||||||
|
cost,
|
||||||
|
has_images: false, // TODO: check images
|
||||||
|
status: "success".to_string(),
|
||||||
|
error_message: None,
|
||||||
|
duration_ms: duration.as_millis() as u64,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update client usage
|
||||||
|
let _ = state.client_manager.update_client_usage(
|
||||||
|
&client_id,
|
||||||
|
response.total_tokens as i64,
|
||||||
|
cost,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
// Convert ProviderResponse to ChatCompletionResponse
|
||||||
|
let chat_response = ChatCompletionResponse {
|
||||||
|
id: format!("chatcmpl-{}", Uuid::new_v4()),
|
||||||
|
object: "chat.completion".to_string(),
|
||||||
|
created: chrono::Utc::now().timestamp() as u64,
|
||||||
|
model: response.model,
|
||||||
|
choices: vec![ChatChoice {
|
||||||
|
index: 0,
|
||||||
|
message: ChatMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: crate::models::MessageContent::Text {
|
||||||
|
content: response.content
|
||||||
|
},
|
||||||
|
reasoning_content: response.reasoning_content,
|
||||||
|
},
|
||||||
|
finish_reason: Some("stop".to_string()),
|
||||||
|
}],
|
||||||
|
usage: Some(Usage {
|
||||||
|
prompt_tokens: response.prompt_tokens,
|
||||||
|
completion_tokens: response.completion_tokens,
|
||||||
|
total_tokens: response.total_tokens,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log successful request
|
||||||
|
info!("Request completed successfully in {:?}", duration);
|
||||||
|
|
||||||
|
Ok(Json(chat_response).into_response())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Record provider failure
|
||||||
|
state.rate_limit_manager.record_provider_failure(&provider_name).await;
|
||||||
|
|
||||||
|
// Log failed request to database
|
||||||
|
let duration = start_time.elapsed();
|
||||||
|
state.request_logger.log_request(crate::logging::RequestLog {
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
client_id: client_id.clone(),
|
||||||
|
provider: provider_name.clone(),
|
||||||
|
model: model.clone(),
|
||||||
|
prompt_tokens: 0,
|
||||||
|
completion_tokens: 0,
|
||||||
|
total_tokens: 0,
|
||||||
|
cost: 0.0,
|
||||||
|
has_images: false,
|
||||||
|
status: "error".to_string(),
|
||||||
|
error_message: Some(e.to_string()),
|
||||||
|
duration_ms: duration.as_millis() as u64,
|
||||||
|
});
|
||||||
|
|
||||||
|
warn!("Request failed after {:?}: {}", duration, e);
|
||||||
|
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/state/mod.rs
Normal file
43
src/state/mod.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
client::ClientManager, database::DbPool, providers::ProviderManager,
|
||||||
|
rate_limiting::RateLimitManager, logging::RequestLogger,
|
||||||
|
models::registry::ModelRegistry,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Shared application state
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub provider_manager: ProviderManager,
|
||||||
|
pub db_pool: DbPool,
|
||||||
|
pub rate_limit_manager: Arc<RateLimitManager>,
|
||||||
|
pub client_manager: Arc<ClientManager>,
|
||||||
|
pub request_logger: Arc<RequestLogger>,
|
||||||
|
pub model_registry: Arc<ModelRegistry>,
|
||||||
|
pub dashboard_tx: broadcast::Sender<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(
|
||||||
|
provider_manager: ProviderManager,
|
||||||
|
db_pool: DbPool,
|
||||||
|
rate_limit_manager: RateLimitManager,
|
||||||
|
model_registry: ModelRegistry,
|
||||||
|
) -> Self {
|
||||||
|
let client_manager = Arc::new(ClientManager::new(db_pool.clone()));
|
||||||
|
let (dashboard_tx, _) = broadcast::channel(100);
|
||||||
|
let request_logger = Arc::new(RequestLogger::new(db_pool.clone(), dashboard_tx.clone()));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
provider_manager,
|
||||||
|
db_pool,
|
||||||
|
rate_limit_manager: Arc::new(rate_limit_manager),
|
||||||
|
client_manager,
|
||||||
|
request_logger,
|
||||||
|
model_registry: Arc::new(model_registry),
|
||||||
|
dashboard_tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/utils/mod.rs
Normal file
3
src/utils/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod tokens;
|
||||||
|
pub mod registry;
|
||||||
|
pub mod streaming;
|
||||||
24
src/utils/registry.rs
Normal file
24
src/utils/registry.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use tracing::info;
|
||||||
|
use crate::models::registry::ModelRegistry;
|
||||||
|
|
||||||
|
const MODELS_DEV_URL: &str = "https://models.dev/api.json";
|
||||||
|
|
||||||
|
pub async fn fetch_registry() -> Result<ModelRegistry> {
|
||||||
|
info!("Fetching model registry from {}", MODELS_DEV_URL);
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let response = client.get(MODELS_DEV_URL).send().await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow::anyhow!("Failed to fetch registry: HTTP {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let registry: ModelRegistry = response.json().await?;
|
||||||
|
info!("Successfully loaded model registry");
|
||||||
|
|
||||||
|
Ok(registry)
|
||||||
|
}
|
||||||
200
src/utils/streaming.rs
Normal file
200
src/utils/streaming.rs
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
use futures::stream::Stream;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::task::{Context, Poll};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use crate::logging::{RequestLogger, RequestLog};
|
||||||
|
use crate::client::ClientManager;
|
||||||
|
use crate::providers::{Provider, ProviderStreamChunk};
|
||||||
|
use crate::errors::AppError;
|
||||||
|
use crate::utils::tokens::estimate_completion_tokens;
|
||||||
|
|
||||||
|
pub struct AggregatingStream<S> {
|
||||||
|
inner: S,
|
||||||
|
client_id: String,
|
||||||
|
provider: Arc<dyn Provider>,
|
||||||
|
model: String,
|
||||||
|
prompt_tokens: u32,
|
||||||
|
has_images: bool,
|
||||||
|
accumulated_content: String,
|
||||||
|
accumulated_reasoning: String,
|
||||||
|
logger: Arc<RequestLogger>,
|
||||||
|
client_manager: Arc<ClientManager>,
|
||||||
|
model_registry: Arc<crate::models::registry::ModelRegistry>,
|
||||||
|
start_time: std::time::Instant,
|
||||||
|
has_logged: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> AggregatingStream<S>
|
||||||
|
where
|
||||||
|
S: Stream<Item = Result<ProviderStreamChunk, AppError>> + Unpin
|
||||||
|
{
|
||||||
|
pub fn new(
|
||||||
|
inner: S,
|
||||||
|
client_id: String,
|
||||||
|
provider: Arc<dyn Provider>,
|
||||||
|
model: String,
|
||||||
|
prompt_tokens: u32,
|
||||||
|
has_images: bool,
|
||||||
|
logger: Arc<RequestLogger>,
|
||||||
|
client_manager: Arc<ClientManager>,
|
||||||
|
model_registry: Arc<crate::models::registry::ModelRegistry>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
client_id,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
prompt_tokens,
|
||||||
|
has_images,
|
||||||
|
accumulated_content: String::new(),
|
||||||
|
accumulated_reasoning: String::new(),
|
||||||
|
logger,
|
||||||
|
client_manager,
|
||||||
|
model_registry,
|
||||||
|
start_time: std::time::Instant::now(),
|
||||||
|
has_logged: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finalize(&mut self) {
|
||||||
|
if self.has_logged {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.has_logged = true;
|
||||||
|
|
||||||
|
let duration = self.start_time.elapsed();
|
||||||
|
let client_id = self.client_id.clone();
|
||||||
|
let provider_name = self.provider.name().to_string();
|
||||||
|
let model = self.model.clone();
|
||||||
|
let logger = self.logger.clone();
|
||||||
|
let client_manager = self.client_manager.clone();
|
||||||
|
let provider = self.provider.clone();
|
||||||
|
let prompt_tokens = self.prompt_tokens;
|
||||||
|
let has_images = self.has_images;
|
||||||
|
let registry = self.model_registry.clone();
|
||||||
|
|
||||||
|
// Estimate completion tokens (including reasoning if present)
|
||||||
|
let content_tokens = estimate_completion_tokens(&self.accumulated_content, &model);
|
||||||
|
let reasoning_tokens = if !self.accumulated_reasoning.is_empty() {
|
||||||
|
estimate_completion_tokens(&self.accumulated_reasoning, &model)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let completion_tokens = content_tokens + reasoning_tokens;
|
||||||
|
let total_tokens = prompt_tokens + completion_tokens;
|
||||||
|
let cost = provider.calculate_cost(&model, prompt_tokens, completion_tokens, ®istry);
|
||||||
|
|
||||||
|
// Spawn a background task to log the completion
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Log to database
|
||||||
|
logger.log_request(RequestLog {
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
client_id: client_id.clone(),
|
||||||
|
provider: provider_name,
|
||||||
|
model,
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
total_tokens,
|
||||||
|
cost,
|
||||||
|
has_images,
|
||||||
|
status: "success".to_string(),
|
||||||
|
error_message: None,
|
||||||
|
duration_ms: duration.as_millis() as u64,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update client usage
|
||||||
|
let _ = client_manager.update_client_usage(
|
||||||
|
&client_id,
|
||||||
|
total_tokens as i64,
|
||||||
|
cost,
|
||||||
|
).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> Stream for AggregatingStream<S>
|
||||||
|
where
|
||||||
|
S: Stream<Item = Result<ProviderStreamChunk, AppError>> + Unpin
|
||||||
|
{
|
||||||
|
type Item = Result<ProviderStreamChunk, AppError>;
|
||||||
|
|
||||||
|
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||||
|
let result = Pin::new(&mut self.inner).poll_next(cx);
|
||||||
|
|
||||||
|
match &result {
|
||||||
|
Poll::Ready(Some(Ok(chunk))) => {
|
||||||
|
self.accumulated_content.push_str(&chunk.content);
|
||||||
|
if let Some(reasoning) = &chunk.reasoning_content {
|
||||||
|
self.accumulated_reasoning.push_str(reasoning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Poll::Ready(Some(Err(_))) => {
|
||||||
|
// If there's an error, we might still want to log what we got so far?
|
||||||
|
// For now, just finalize if we have content
|
||||||
|
if !self.accumulated_content.is_empty() {
|
||||||
|
self.finalize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Poll::Ready(None) => {
|
||||||
|
self.finalize();
|
||||||
|
}
|
||||||
|
Poll::Pending => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use futures::stream::{self, StreamExt};
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
// Simple mock provider for testing
|
||||||
|
struct MockProvider;
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Provider for MockProvider {
|
||||||
|
fn name(&self) -> &str { "mock" }
|
||||||
|
fn supports_model(&self, _model: &str) -> bool { true }
|
||||||
|
fn supports_multimodal(&self) -> bool { false }
|
||||||
|
async fn chat_completion(&self, _req: crate::models::UnifiedRequest) -> Result<crate::providers::ProviderResponse, AppError> { unimplemented!() }
|
||||||
|
async fn chat_completion_stream(&self, _req: crate::models::UnifiedRequest) -> Result<futures::stream::BoxStream<'static, Result<ProviderStreamChunk, AppError>>, AppError> { unimplemented!() }
|
||||||
|
fn estimate_tokens(&self, _req: &crate::models::UnifiedRequest) -> Result<u32> { Ok(10) }
|
||||||
|
fn calculate_cost(&self, _model: &str, _p: u32, _c: u32, _r: &crate::models::registry::ModelRegistry) -> f64 { 0.05 }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_aggregating_stream() {
|
||||||
|
let chunks = vec![
|
||||||
|
Ok(ProviderStreamChunk { content: "Hello".to_string(), finish_reason: None, model: "test".to_string() }),
|
||||||
|
Ok(ProviderStreamChunk { content: " World".to_string(), finish_reason: Some("stop".to_string()), model: "test".to_string() }),
|
||||||
|
];
|
||||||
|
let inner_stream = stream::iter(chunks);
|
||||||
|
|
||||||
|
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||||
|
let logger = Arc::new(RequestLogger::new(pool.clone()));
|
||||||
|
let client_manager = Arc::new(ClientManager::new(pool.clone()));
|
||||||
|
let registry = Arc::new(crate::models::registry::ModelRegistry { providers: std::collections::HashMap::new() });
|
||||||
|
|
||||||
|
let mut agg_stream = AggregatingStream::new(
|
||||||
|
inner_stream,
|
||||||
|
"client_1".to_string(),
|
||||||
|
Arc::new(MockProvider),
|
||||||
|
"test".to_string(),
|
||||||
|
10,
|
||||||
|
false,
|
||||||
|
logger,
|
||||||
|
client_manager,
|
||||||
|
registry,
|
||||||
|
);
|
||||||
|
|
||||||
|
while let Some(item) = agg_stream.next().await {
|
||||||
|
assert!(item.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(agg_stream.accumulated_content, "Hello World");
|
||||||
|
assert!(agg_stream.has_logged);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/utils/tokens.rs
Normal file
51
src/utils/tokens.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use tiktoken_rs::get_bpe_from_model;
|
||||||
|
use crate::models::UnifiedRequest;
|
||||||
|
|
||||||
|
/// Count tokens for a given model and text
|
||||||
|
pub fn count_tokens(model: &str, text: &str) -> u32 {
|
||||||
|
// If we can't get the bpe for the model, fallback to a safe default (cl100k_base for GPT-4/o1)
|
||||||
|
let bpe = get_bpe_from_model(model).unwrap_or_else(|_| {
|
||||||
|
tiktoken_rs::cl100k_base().expect("Failed to get cl100k_base encoding")
|
||||||
|
});
|
||||||
|
|
||||||
|
bpe.encode_with_special_tokens(text).len() as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimate tokens for a unified request
|
||||||
|
pub fn estimate_request_tokens(model: &str, request: &UnifiedRequest) -> u32 {
|
||||||
|
let mut total_tokens = 0;
|
||||||
|
|
||||||
|
// Base tokens per message for OpenAI (approximate)
|
||||||
|
let tokens_per_message = 3;
|
||||||
|
let _tokens_per_name = 1;
|
||||||
|
|
||||||
|
for msg in &request.messages {
|
||||||
|
total_tokens += tokens_per_message;
|
||||||
|
|
||||||
|
for part in &msg.content {
|
||||||
|
match part {
|
||||||
|
crate::models::ContentPart::Text { text } => {
|
||||||
|
total_tokens += count_tokens(model, text);
|
||||||
|
}
|
||||||
|
crate::models::ContentPart::Image { .. } => {
|
||||||
|
// Vision models usually have a fixed cost or calculation based on size
|
||||||
|
// For now, let's use a conservative estimate of 1000 tokens
|
||||||
|
total_tokens += 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add name tokens if we had names (we don't in UnifiedMessage yet)
|
||||||
|
// total_tokens += tokens_per_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 3 tokens for the assistant reply header
|
||||||
|
total_tokens += 3;
|
||||||
|
|
||||||
|
total_tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimate tokens for completion text
|
||||||
|
pub fn estimate_completion_tokens(text: &str, model: &str) -> u32 {
|
||||||
|
count_tokens(model, text)
|
||||||
|
}
|
||||||
1119
static/css/dashboard.css
Normal file
1119
static/css/dashboard.css
Normal file
File diff suppressed because it is too large
Load Diff
175
static/index.html
Normal file
175
static/index.html
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LLM Proxy Gateway - Admin Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="/css/dashboard.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/luxon@3.4.4/build/global/luxon.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Login Screen -->
|
||||||
|
<div id="login-screen" class="login-container">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<i class="fas fa-robot login-icon"></i>
|
||||||
|
<h1>LLM Proxy Gateway</h1>
|
||||||
|
<p class="login-subtitle">Admin Dashboard</p>
|
||||||
|
</div>
|
||||||
|
<form id="login-form" class="login-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">
|
||||||
|
<i class="fas fa-user"></i> Username
|
||||||
|
</label>
|
||||||
|
<input type="text" id="username" name="username" placeholder="admin" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">
|
||||||
|
<i class="fas fa-lock"></i> Password
|
||||||
|
</label>
|
||||||
|
<input type="password" id="password" name="password" placeholder="••••••••" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="login-btn">
|
||||||
|
<i class="fas fa-sign-in-alt"></i> Sign In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="login-footer">
|
||||||
|
<p>Default credentials: admin / admin123</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="login-error" class="error-message" style="display: none;">
|
||||||
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
|
<span>Invalid credentials. Please try again.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Dashboard -->
|
||||||
|
<div id="dashboard" class="dashboard-container" style="display: none;">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<nav class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<div class="logo">
|
||||||
|
<i class="fas fa-robot"></i>
|
||||||
|
<span>LLM Proxy</span>
|
||||||
|
</div>
|
||||||
|
<button class="sidebar-toggle" id="sidebar-toggle">
|
||||||
|
<i class="fas fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-menu">
|
||||||
|
<div class="menu-section">
|
||||||
|
<h3 class="menu-title">MAIN</h3>
|
||||||
|
<a href="#overview" class="menu-item active" data-page="overview">
|
||||||
|
<i class="fas fa-tachometer-alt"></i>
|
||||||
|
<span>Overview</span>
|
||||||
|
</a>
|
||||||
|
<a href="#analytics" class="menu-item" data-page="analytics">
|
||||||
|
<i class="fas fa-chart-line"></i>
|
||||||
|
<span>Analytics</span>
|
||||||
|
</a>
|
||||||
|
<a href="#costs" class="menu-item" data-page="costs">
|
||||||
|
<i class="fas fa-dollar-sign"></i>
|
||||||
|
<span>Cost Management</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-section">
|
||||||
|
<h3 class="menu-title">MANAGEMENT</h3>
|
||||||
|
<a href="#clients" class="menu-item" data-page="clients">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
<span>Client Management</span>
|
||||||
|
</a>
|
||||||
|
<a href="#providers" class="menu-item" data-page="providers">
|
||||||
|
<i class="fas fa-server"></i>
|
||||||
|
<span>Providers</span>
|
||||||
|
</a>
|
||||||
|
<a href="#monitoring" class="menu-item" data-page="monitoring">
|
||||||
|
<i class="fas fa-heartbeat"></i>
|
||||||
|
<span>Real-time Monitoring</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-section">
|
||||||
|
<h3 class="menu-title">SYSTEM</h3>
|
||||||
|
<a href="#settings" class="menu-item" data-page="settings">
|
||||||
|
<i class="fas fa-cog"></i>
|
||||||
|
<span>Settings</span>
|
||||||
|
</a>
|
||||||
|
<a href="#logs" class="menu-item" data-page="logs">
|
||||||
|
<i class="fas fa-file-alt"></i>
|
||||||
|
<span>System Logs</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-avatar">
|
||||||
|
<i class="fas fa-user-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="user-details">
|
||||||
|
<span class="user-name">Administrator</span>
|
||||||
|
<span class="user-role">Super Admin</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="logout-btn" id="logout-btn">
|
||||||
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content">
|
||||||
|
<!-- Top Navigation -->
|
||||||
|
<header class="top-nav">
|
||||||
|
<div class="nav-left">
|
||||||
|
<h1 class="page-title" id="page-title">Dashboard Overview</h1>
|
||||||
|
</div>
|
||||||
|
<div class="nav-right">
|
||||||
|
<div class="nav-item">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
<span class="badge">3</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<i class="fas fa-sync-alt" id="refresh-btn"></i>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<span id="current-time">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Page Content -->
|
||||||
|
<div class="page-content" id="page-content">
|
||||||
|
<!-- Overview page will be loaded here -->
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WebSocket Status Indicator -->
|
||||||
|
<div class="ws-status" id="ws-status">
|
||||||
|
<span class="ws-dot"></span>
|
||||||
|
<span class="ws-text">Connecting...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="/js/auth.js"></script>
|
||||||
|
<script src="/js/dashboard.js"></script>
|
||||||
|
<script src="/js/websocket.js"></script>
|
||||||
|
<script src="/js/charts.js"></script>
|
||||||
|
<script src="/js/pages/overview.js"></script>
|
||||||
|
<script src="/js/pages/analytics.js"></script>
|
||||||
|
<script src="/js/pages/costs.js"></script>
|
||||||
|
<script src="/js/pages/clients.js"></script>
|
||||||
|
<script src="/js/pages/providers.js"></script>
|
||||||
|
<script src="/js/pages/monitoring.js"></script>
|
||||||
|
<script src="/js/pages/settings.js"></script>
|
||||||
|
<script src="/js/pages/logs.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
269
static/js/auth.js
Normal file
269
static/js/auth.js
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
// Authentication Module for LLM Proxy Dashboard
|
||||||
|
|
||||||
|
class AuthManager {
|
||||||
|
constructor() {
|
||||||
|
this.isAuthenticated = false;
|
||||||
|
this.token = null;
|
||||||
|
this.user = null;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Check for existing session
|
||||||
|
const savedToken = localStorage.getItem('dashboard_token');
|
||||||
|
const savedUser = localStorage.getItem('dashboard_user');
|
||||||
|
|
||||||
|
if (savedToken && savedUser) {
|
||||||
|
this.token = savedToken;
|
||||||
|
this.user = JSON.parse(savedUser);
|
||||||
|
this.isAuthenticated = true;
|
||||||
|
this.showDashboard();
|
||||||
|
} else {
|
||||||
|
this.showLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup login form
|
||||||
|
this.setupLoginForm();
|
||||||
|
this.setupLogout();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupLoginForm() {
|
||||||
|
const loginForm = document.getElementById('login-form');
|
||||||
|
if (!loginForm) return;
|
||||||
|
|
||||||
|
loginForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const username = document.getElementById('username').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
await this.login(username, password);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupLogout() {
|
||||||
|
const logoutBtn = document.getElementById('logout-btn');
|
||||||
|
if (!logoutBtn) return;
|
||||||
|
|
||||||
|
logoutBtn.addEventListener('click', () => {
|
||||||
|
this.logout();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(username, password) {
|
||||||
|
const errorElement = document.getElementById('login-error');
|
||||||
|
const loginBtn = document.querySelector('.login-btn');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Show loading state
|
||||||
|
loginBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Authenticating...';
|
||||||
|
loginBtn.disabled = true;
|
||||||
|
|
||||||
|
// Simple authentication - in production, this would call an API
|
||||||
|
// For now, using mock authentication
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
if (username === 'admin' && password === 'admin123') {
|
||||||
|
// Successful login
|
||||||
|
this.token = this.generateToken();
|
||||||
|
this.user = {
|
||||||
|
username: 'admin',
|
||||||
|
name: 'Administrator',
|
||||||
|
role: 'Super Admin',
|
||||||
|
avatar: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem('dashboard_token', this.token);
|
||||||
|
localStorage.setItem('dashboard_user', JSON.stringify(this.user));
|
||||||
|
|
||||||
|
this.isAuthenticated = true;
|
||||||
|
this.showDashboard();
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
this.showToast('Successfully logged in!', 'success');
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid credentials');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Show error
|
||||||
|
errorElement.style.display = 'flex';
|
||||||
|
errorElement.querySelector('span').textContent = error.message;
|
||||||
|
|
||||||
|
// Reset button
|
||||||
|
loginBtn.innerHTML = '<i class="fas fa-sign-in-alt"></i> Sign In';
|
||||||
|
loginBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
// Clear localStorage
|
||||||
|
localStorage.removeItem('dashboard_token');
|
||||||
|
localStorage.removeItem('dashboard_user');
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
this.isAuthenticated = false;
|
||||||
|
this.token = null;
|
||||||
|
this.user = null;
|
||||||
|
|
||||||
|
// Show login screen
|
||||||
|
this.showLogin();
|
||||||
|
|
||||||
|
// Show logout message
|
||||||
|
this.showToast('Successfully logged out', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
generateToken() {
|
||||||
|
// Generate a simple token for demo purposes
|
||||||
|
// In production, this would come from the server
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const random = Math.random().toString(36).substring(2);
|
||||||
|
return btoa(`${timestamp}:${random}`).replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
showLogin() {
|
||||||
|
const loginScreen = document.getElementById('login-screen');
|
||||||
|
const dashboard = document.getElementById('dashboard');
|
||||||
|
|
||||||
|
if (loginScreen) loginScreen.style.display = 'flex';
|
||||||
|
if (dashboard) dashboard.style.display = 'none';
|
||||||
|
|
||||||
|
// Clear form
|
||||||
|
const loginForm = document.getElementById('login-form');
|
||||||
|
if (loginForm) loginForm.reset();
|
||||||
|
|
||||||
|
// Hide error
|
||||||
|
const errorElement = document.getElementById('login-error');
|
||||||
|
if (errorElement) errorElement.style.display = 'none';
|
||||||
|
|
||||||
|
// Reset button
|
||||||
|
const loginBtn = document.querySelector('.login-btn');
|
||||||
|
if (loginBtn) {
|
||||||
|
loginBtn.innerHTML = '<i class="fas fa-sign-in-alt"></i> Sign In';
|
||||||
|
loginBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showDashboard() {
|
||||||
|
const loginScreen = document.getElementById('login-screen');
|
||||||
|
const dashboard = document.getElementById('dashboard');
|
||||||
|
|
||||||
|
if (loginScreen) loginScreen.style.display = 'none';
|
||||||
|
if (dashboard) dashboard.style.display = 'flex';
|
||||||
|
|
||||||
|
// Update user info in sidebar
|
||||||
|
this.updateUserInfo();
|
||||||
|
|
||||||
|
// Initialize dashboard components
|
||||||
|
if (typeof window.initDashboard === 'function') {
|
||||||
|
window.initDashboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUserInfo() {
|
||||||
|
const userNameElement = document.querySelector('.user-name');
|
||||||
|
const userRoleElement = document.querySelector('.user-role');
|
||||||
|
|
||||||
|
if (userNameElement && this.user) {
|
||||||
|
userNameElement.textContent = this.user.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRoleElement && this.user) {
|
||||||
|
userRoleElement.textContent = this.user.role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthHeaders() {
|
||||||
|
if (!this.token) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
'Authorization': `Bearer ${this.token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchWithAuth(url, options = {}) {
|
||||||
|
const headers = this.getAuthHeaders();
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
// Token expired or invalid
|
||||||
|
this.logout();
|
||||||
|
throw new Error('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(message, type = 'info') {
|
||||||
|
// Create toast container if it doesn't exist
|
||||||
|
let container = document.querySelector('.toast-container');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'toast-container';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create toast
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast ${type}`;
|
||||||
|
|
||||||
|
// Set icon based on type
|
||||||
|
let icon = 'info-circle';
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
icon = 'check-circle';
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
icon = 'exclamation-circle';
|
||||||
|
break;
|
||||||
|
case 'warning':
|
||||||
|
icon = 'exclamation-triangle';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.innerHTML = `
|
||||||
|
<i class="fas fa-${icon} toast-icon"></i>
|
||||||
|
<div class="toast-content">
|
||||||
|
<div class="toast-title">${type.charAt(0).toUpperCase() + type.slice(1)}</div>
|
||||||
|
<div class="toast-message">${message}</div>
|
||||||
|
</div>
|
||||||
|
<button class="toast-close">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add close functionality
|
||||||
|
const closeBtn = toast.querySelector('.toast-close');
|
||||||
|
closeBtn.addEventListener('click', () => {
|
||||||
|
toast.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to container
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
// Auto-remove after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (toast.parentNode) {
|
||||||
|
toast.remove();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize auth manager when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.authManager = new AuthManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = AuthManager;
|
||||||
|
}
|
||||||
533
static/js/charts.js
Normal file
533
static/js/charts.js
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
// Chart.js Configuration and Helpers
|
||||||
|
|
||||||
|
class ChartManager {
|
||||||
|
constructor() {
|
||||||
|
this.charts = new Map();
|
||||||
|
this.defaultColors = [
|
||||||
|
'#3b82f6', // Blue
|
||||||
|
'#10b981', // Green
|
||||||
|
'#f59e0b', // Yellow
|
||||||
|
'#ef4444', // Red
|
||||||
|
'#8b5cf6', // Purple
|
||||||
|
'#ec4899', // Pink
|
||||||
|
'#06b6d4', // Cyan
|
||||||
|
'#84cc16', // Lime
|
||||||
|
'#f97316', // Orange
|
||||||
|
'#6366f1', // Indigo
|
||||||
|
];
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Register Chart.js plugins if needed
|
||||||
|
this.registerPlugins();
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPlugins() {
|
||||||
|
// Register a plugin for tooltip background
|
||||||
|
Chart.register({
|
||||||
|
id: 'customTooltip',
|
||||||
|
beforeDraw: (chart) => {
|
||||||
|
if (chart.tooltip._active && chart.tooltip._active.length) {
|
||||||
|
const ctx = chart.ctx;
|
||||||
|
const activePoint = chart.tooltip._active[0];
|
||||||
|
const x = activePoint.element.x;
|
||||||
|
const topY = chart.scales.y.top;
|
||||||
|
const bottomY = chart.scales.y.bottom;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.setLineDash([5, 5]);
|
||||||
|
ctx.moveTo(x, topY);
|
||||||
|
ctx.lineTo(x, bottomY);
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeStyle = 'rgba(0, 0, 0, 0.1)';
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createChart(canvasId, config) {
|
||||||
|
const canvas = document.getElementById(canvasId);
|
||||||
|
if (!canvas) {
|
||||||
|
console.warn(`Canvas element #${canvasId} not found`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy existing chart if it exists
|
||||||
|
if (this.charts.has(canvasId)) {
|
||||||
|
this.charts.get(canvasId).destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new chart
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const chart = new Chart(ctx, {
|
||||||
|
...config,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
labels: {
|
||||||
|
padding: 20,
|
||||||
|
usePointStyle: true,
|
||||||
|
pointStyle: 'circle'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
titleColor: '#1e293b',
|
||||||
|
bodyColor: '#1e293b',
|
||||||
|
borderColor: '#e2e8f0',
|
||||||
|
borderWidth: 1,
|
||||||
|
cornerRadius: 6,
|
||||||
|
padding: 12,
|
||||||
|
boxPadding: 6,
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
let label = context.dataset.label || '';
|
||||||
|
if (label) {
|
||||||
|
label += ': ';
|
||||||
|
}
|
||||||
|
if (context.parsed.y !== null) {
|
||||||
|
label += context.parsed.y.toLocaleString();
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'nearest'
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
color: 'rgba(0, 0, 0, 0.05)'
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: '#64748b'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
color: 'rgba(0, 0, 0, 0.05)'
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: '#64748b',
|
||||||
|
callback: function(value) {
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...config.options
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store chart reference
|
||||||
|
this.charts.set(canvasId, chart);
|
||||||
|
|
||||||
|
return chart;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyChart(canvasId) {
|
||||||
|
if (this.charts.has(canvasId)) {
|
||||||
|
this.charts.get(canvasId).destroy();
|
||||||
|
this.charts.delete(canvasId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyAllCharts() {
|
||||||
|
this.charts.forEach((chart, canvasId) => {
|
||||||
|
chart.destroy();
|
||||||
|
});
|
||||||
|
this.charts.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart templates
|
||||||
|
createLineChart(canvasId, data, options = {}) {
|
||||||
|
const config = {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.labels || [],
|
||||||
|
datasets: data.datasets.map((dataset, index) => ({
|
||||||
|
label: dataset.label,
|
||||||
|
data: dataset.data,
|
||||||
|
borderColor: dataset.color || this.defaultColors[index % this.defaultColors.length],
|
||||||
|
backgroundColor: dataset.fill ? this.hexToRgba(dataset.color || this.defaultColors[index % this.defaultColors.length], 0.1) : 'transparent',
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
pointBackgroundColor: dataset.color || this.defaultColors[index % this.defaultColors.length],
|
||||||
|
pointBorderColor: '#ffffff',
|
||||||
|
pointBorderWidth: 2,
|
||||||
|
fill: dataset.fill || false,
|
||||||
|
tension: 0.4,
|
||||||
|
...dataset
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
let label = context.dataset.label || '';
|
||||||
|
if (label) {
|
||||||
|
label += ': ';
|
||||||
|
}
|
||||||
|
if (context.parsed.y !== null) {
|
||||||
|
label += context.parsed.y.toLocaleString();
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.createChart(canvasId, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
createBarChart(canvasId, data, options = {}) {
|
||||||
|
const config = {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: data.labels || [],
|
||||||
|
datasets: data.datasets.map((dataset, index) => ({
|
||||||
|
label: dataset.label,
|
||||||
|
data: dataset.data,
|
||||||
|
backgroundColor: dataset.color || this.defaultColors[index % this.defaultColors.length],
|
||||||
|
borderColor: dataset.borderColor || '#ffffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
borderSkipped: false,
|
||||||
|
...dataset
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
let label = context.dataset.label || '';
|
||||||
|
if (label) {
|
||||||
|
label += ': ';
|
||||||
|
}
|
||||||
|
if (context.parsed.y !== null) {
|
||||||
|
label += context.parsed.y.toLocaleString();
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.createChart(canvasId, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
createPieChart(canvasId, data, options = {}) {
|
||||||
|
const config = {
|
||||||
|
type: 'pie',
|
||||||
|
data: {
|
||||||
|
labels: data.labels || [],
|
||||||
|
datasets: [{
|
||||||
|
data: data.data || [],
|
||||||
|
backgroundColor: data.colors || this.defaultColors.slice(0, data.data.length),
|
||||||
|
borderColor: '#ffffff',
|
||||||
|
borderWidth: 2,
|
||||||
|
hoverOffset: 15
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'right',
|
||||||
|
labels: {
|
||||||
|
padding: 20,
|
||||||
|
usePointStyle: true,
|
||||||
|
pointStyle: 'circle'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
const label = context.label || '';
|
||||||
|
const value = context.raw || 0;
|
||||||
|
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||||
|
const percentage = Math.round((value / total) * 100);
|
||||||
|
return `${label}: ${value.toLocaleString()} (${percentage}%)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.createChart(canvasId, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
createDoughnutChart(canvasId, data, options = {}) {
|
||||||
|
const config = {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: data.labels || [],
|
||||||
|
datasets: [{
|
||||||
|
data: data.data || [],
|
||||||
|
backgroundColor: data.colors || this.defaultColors.slice(0, data.data.length),
|
||||||
|
borderColor: '#ffffff',
|
||||||
|
borderWidth: 2,
|
||||||
|
hoverOffset: 15
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
cutout: '60%',
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'right',
|
||||||
|
labels: {
|
||||||
|
padding: 20,
|
||||||
|
usePointStyle: true,
|
||||||
|
pointStyle: 'circle'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
const label = context.label || '';
|
||||||
|
const value = context.raw || 0;
|
||||||
|
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||||
|
const percentage = Math.round((value / total) * 100);
|
||||||
|
return `${label}: ${value.toLocaleString()} (${percentage}%)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.createChart(canvasId, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
createHorizontalBarChart(canvasId, data, options = {}) {
|
||||||
|
const config = {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: data.labels || [],
|
||||||
|
datasets: data.datasets.map((dataset, index) => ({
|
||||||
|
label: dataset.label,
|
||||||
|
data: dataset.data,
|
||||||
|
backgroundColor: dataset.color || this.defaultColors[index % this.defaultColors.length],
|
||||||
|
borderColor: dataset.borderColor || '#ffffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
...dataset
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: 'y',
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
let label = context.dataset.label || '';
|
||||||
|
if (label) {
|
||||||
|
label += ': ';
|
||||||
|
}
|
||||||
|
if (context.parsed.x !== null) {
|
||||||
|
label += context.parsed.x.toLocaleString();
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
color: 'rgba(0, 0, 0, 0.05)'
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: '#64748b',
|
||||||
|
callback: function(value) {
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: '#64748b'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.createChart(canvasId, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
hexToRgba(hex, alpha = 1) {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateTimeLabels(hours = 24) {
|
||||||
|
const now = luxon.DateTime.now();
|
||||||
|
const labels = [];
|
||||||
|
|
||||||
|
for (let i = hours - 1; i >= 0; i--) {
|
||||||
|
const time = now.minus({ hours: i });
|
||||||
|
labels.push(time.toFormat('HH:00'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateDateLabels(days = 7) {
|
||||||
|
const now = luxon.DateTime.now();
|
||||||
|
const labels = [];
|
||||||
|
|
||||||
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
|
const date = now.minus({ days: i });
|
||||||
|
labels.push(date.toFormat('MMM dd'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demo data generators
|
||||||
|
generateDemoTimeSeries(hours = 24, seriesCount = 1) {
|
||||||
|
const labels = this.generateTimeLabels(hours);
|
||||||
|
const datasets = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < seriesCount; i++) {
|
||||||
|
const data = [];
|
||||||
|
let value = Math.floor(Math.random() * 100) + 50;
|
||||||
|
|
||||||
|
for (let j = 0; j < hours; j++) {
|
||||||
|
// Add some randomness but keep trend
|
||||||
|
value += Math.floor(Math.random() * 20) - 10;
|
||||||
|
value = Math.max(10, value);
|
||||||
|
data.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
datasets.push({
|
||||||
|
label: `Series ${i + 1}`,
|
||||||
|
data: data,
|
||||||
|
color: this.defaultColors[i % this.defaultColors.length]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { labels, datasets };
|
||||||
|
}
|
||||||
|
|
||||||
|
generateDemoBarData(labels, seriesCount = 1) {
|
||||||
|
const datasets = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < seriesCount; i++) {
|
||||||
|
const data = labels.map(() => Math.floor(Math.random() * 100) + 20);
|
||||||
|
|
||||||
|
datasets.push({
|
||||||
|
label: `Dataset ${i + 1}`,
|
||||||
|
data: data,
|
||||||
|
color: this.defaultColors[i % this.defaultColors.length]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { labels, datasets };
|
||||||
|
}
|
||||||
|
|
||||||
|
generateDemoPieData(labels) {
|
||||||
|
const data = labels.map(() => Math.floor(Math.random() * 100) + 10);
|
||||||
|
const total = data.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: labels,
|
||||||
|
data: data,
|
||||||
|
colors: labels.map((_, i) => this.defaultColors[i % this.defaultColors.length])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update chart data
|
||||||
|
updateChartData(canvasId, newData) {
|
||||||
|
const chart = this.charts.get(canvasId);
|
||||||
|
if (!chart) return;
|
||||||
|
|
||||||
|
chart.data.labels = newData.labels || chart.data.labels;
|
||||||
|
|
||||||
|
if (newData.datasets) {
|
||||||
|
newData.datasets.forEach((dataset, index) => {
|
||||||
|
if (chart.data.datasets[index]) {
|
||||||
|
chart.data.datasets[index].data = dataset.data;
|
||||||
|
if (dataset.label) {
|
||||||
|
chart.data.datasets[index].label = dataset.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add data point to time series
|
||||||
|
addDataPoint(canvasId, newPoint, datasetIndex = 0) {
|
||||||
|
const chart = this.charts.get(canvasId);
|
||||||
|
if (!chart || chart.config.type !== 'line') return;
|
||||||
|
|
||||||
|
const dataset = chart.data.datasets[datasetIndex];
|
||||||
|
if (!dataset) return;
|
||||||
|
|
||||||
|
// Add new point
|
||||||
|
dataset.data.push(newPoint);
|
||||||
|
|
||||||
|
// Remove oldest point if we have too many
|
||||||
|
if (dataset.data.length > 100) {
|
||||||
|
dataset.data.shift();
|
||||||
|
chart.data.labels.shift();
|
||||||
|
} else {
|
||||||
|
// Add new label
|
||||||
|
const now = luxon.DateTime.now();
|
||||||
|
chart.data.labels.push(now.toFormat('HH:mm:ss'));
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize chart manager when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.chartManager = new ChartManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = ChartManager;
|
||||||
|
}
|
||||||
872
static/js/dashboard.js
Normal file
872
static/js/dashboard.js
Normal file
@@ -0,0 +1,872 @@
|
|||||||
|
// Main Dashboard Controller
|
||||||
|
|
||||||
|
class Dashboard {
|
||||||
|
constructor() {
|
||||||
|
this.currentPage = 'overview';
|
||||||
|
this.pages = {};
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Initialize only if authenticated
|
||||||
|
if (!window.authManager || !window.authManager.isAuthenticated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setupNavigation();
|
||||||
|
this.setupSidebar();
|
||||||
|
this.setupRefresh();
|
||||||
|
this.updateTime();
|
||||||
|
this.loadPage(this.currentPage);
|
||||||
|
|
||||||
|
// Start time updates
|
||||||
|
setInterval(() => this.updateTime(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupNavigation() {
|
||||||
|
// Handle menu item clicks
|
||||||
|
const menuItems = document.querySelectorAll('.menu-item');
|
||||||
|
menuItems.forEach(item => {
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Get page from data attribute or href
|
||||||
|
const page = item.getAttribute('data-page') ||
|
||||||
|
item.getAttribute('href').substring(1);
|
||||||
|
|
||||||
|
// Update active state
|
||||||
|
menuItems.forEach(i => i.classList.remove('active'));
|
||||||
|
item.classList.add('active');
|
||||||
|
|
||||||
|
// Load page
|
||||||
|
this.loadPage(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle hash changes (browser back/forward)
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
const page = window.location.hash.substring(1) || 'overview';
|
||||||
|
this.loadPage(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSidebar() {
|
||||||
|
const toggleBtn = document.getElementById('sidebar-toggle');
|
||||||
|
const sidebar = document.querySelector('.sidebar');
|
||||||
|
|
||||||
|
if (toggleBtn && sidebar) {
|
||||||
|
toggleBtn.addEventListener('click', () => {
|
||||||
|
sidebar.classList.toggle('collapsed');
|
||||||
|
|
||||||
|
// Save preference
|
||||||
|
const isCollapsed = sidebar.classList.contains('collapsed');
|
||||||
|
localStorage.setItem('sidebar_collapsed', isCollapsed);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load saved preference
|
||||||
|
const savedState = localStorage.getItem('sidebar_collapsed');
|
||||||
|
if (savedState === 'true') {
|
||||||
|
sidebar.classList.add('collapsed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupRefresh() {
|
||||||
|
const refreshBtn = document.getElementById('refresh-btn');
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.addEventListener('click', () => {
|
||||||
|
this.refreshCurrentPage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTime() {
|
||||||
|
const timeElement = document.getElementById('current-time');
|
||||||
|
if (!timeElement) return;
|
||||||
|
|
||||||
|
const now = luxon.DateTime.now();
|
||||||
|
timeElement.textContent = now.toFormat('HH:mm:ss');
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPage(page) {
|
||||||
|
// Update current page
|
||||||
|
this.currentPage = page;
|
||||||
|
|
||||||
|
// Update URL hash
|
||||||
|
window.location.hash = page;
|
||||||
|
|
||||||
|
// Update page title
|
||||||
|
this.updatePageTitle(page);
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
this.showLoading();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load page content
|
||||||
|
await this.loadPageContent(page);
|
||||||
|
|
||||||
|
// Initialize page-specific functionality
|
||||||
|
await this.initializePage(page);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error loading page ${page}:`, error);
|
||||||
|
this.showError(`Failed to load ${page} page`);
|
||||||
|
} finally {
|
||||||
|
// Hide loading state
|
||||||
|
this.hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePageTitle(page) {
|
||||||
|
const titleElement = document.getElementById('page-title');
|
||||||
|
if (!titleElement) return;
|
||||||
|
|
||||||
|
const titles = {
|
||||||
|
'overview': 'Dashboard Overview',
|
||||||
|
'analytics': 'Usage Analytics',
|
||||||
|
'costs': 'Cost Management',
|
||||||
|
'clients': 'Client Management',
|
||||||
|
'providers': 'Provider Configuration',
|
||||||
|
'monitoring': 'Real-time Monitoring',
|
||||||
|
'settings': 'System Settings',
|
||||||
|
'logs': 'System Logs'
|
||||||
|
};
|
||||||
|
|
||||||
|
titleElement.textContent = titles[page] || 'Dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoading() {
|
||||||
|
const content = document.getElementById('page-content');
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
content.classList.add('loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
hideLoading() {
|
||||||
|
const content = document.getElementById('page-content');
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
content.classList.remove('loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPageContent(page) {
|
||||||
|
const content = document.getElementById('page-content');
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
// For now, we'll generate content dynamically
|
||||||
|
// In a real app, you might fetch HTML templates or use a framework
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
switch (page) {
|
||||||
|
case 'overview':
|
||||||
|
html = await this.getOverviewContent();
|
||||||
|
break;
|
||||||
|
case 'analytics':
|
||||||
|
html = await this.getAnalyticsContent();
|
||||||
|
break;
|
||||||
|
case 'costs':
|
||||||
|
html = await this.getCostsContent();
|
||||||
|
break;
|
||||||
|
case 'clients':
|
||||||
|
html = await this.getClientsContent();
|
||||||
|
break;
|
||||||
|
case 'providers':
|
||||||
|
html = await this.getProvidersContent();
|
||||||
|
break;
|
||||||
|
case 'monitoring':
|
||||||
|
html = await this.getMonitoringContent();
|
||||||
|
break;
|
||||||
|
case 'settings':
|
||||||
|
html = await this.getSettingsContent();
|
||||||
|
break;
|
||||||
|
case 'logs':
|
||||||
|
html = await this.getLogsContent();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
html = '<div class="empty-state"><h3>Page not found</h3></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
content.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializePage(page) {
|
||||||
|
// Initialize page-specific JavaScript
|
||||||
|
switch (page) {
|
||||||
|
case 'overview':
|
||||||
|
if (typeof window.initOverview === 'function') {
|
||||||
|
await window.initOverview();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'analytics':
|
||||||
|
if (typeof window.initAnalytics === 'function') {
|
||||||
|
await window.initAnalytics();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'costs':
|
||||||
|
if (typeof window.initCosts === 'function') {
|
||||||
|
await window.initCosts();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'clients':
|
||||||
|
if (typeof window.initClients === 'function') {
|
||||||
|
await window.initClients();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'providers':
|
||||||
|
if (typeof window.initProviders === 'function') {
|
||||||
|
await window.initProviders();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'monitoring':
|
||||||
|
if (typeof window.initMonitoring === 'function') {
|
||||||
|
await window.initMonitoring();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'settings':
|
||||||
|
if (typeof window.initSettings === 'function') {
|
||||||
|
await window.initSettings();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'logs':
|
||||||
|
if (typeof window.initLogs === 'function') {
|
||||||
|
await window.initLogs();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshCurrentPage() {
|
||||||
|
this.loadPage(this.currentPage);
|
||||||
|
|
||||||
|
// Show refresh animation
|
||||||
|
const refreshBtn = document.getElementById('refresh-btn');
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.classList.add('fa-spin');
|
||||||
|
setTimeout(() => {
|
||||||
|
refreshBtn.classList.remove('fa-spin');
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show toast notification
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Page refreshed', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
const content = document.getElementById('page-content');
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<h3>Error</h3>
|
||||||
|
<p>${message}</p>
|
||||||
|
<button class="btn btn-primary" onclick="window.dashboard.refreshCurrentPage()">
|
||||||
|
<i class="fas fa-redo"></i> Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page content generators
|
||||||
|
async getOverviewContent() {
|
||||||
|
return `
|
||||||
|
<div class="stats-grid" id="overview-stats">
|
||||||
|
<!-- Stats will be loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container">
|
||||||
|
<div class="chart-header">
|
||||||
|
<h3 class="chart-title">Request Volume (Last 24 Hours)</h3>
|
||||||
|
<div class="chart-controls">
|
||||||
|
<button class="chart-control-btn active" data-period="24h">24H</button>
|
||||||
|
<button class="chart-control-btn" data-period="7d">7D</button>
|
||||||
|
<button class="chart-control-btn" data-period="30d">30D</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<canvas id="requests-chart" height="300"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="chart-container">
|
||||||
|
<div class="chart-header">
|
||||||
|
<h3 class="chart-title">Provider Distribution</h3>
|
||||||
|
</div>
|
||||||
|
<canvas id="providers-chart" height="250"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container">
|
||||||
|
<div class="chart-header">
|
||||||
|
<h3 class="chart-title">System Health</h3>
|
||||||
|
</div>
|
||||||
|
<div id="system-health">
|
||||||
|
<!-- Health indicators will be loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title">Recent Requests</h3>
|
||||||
|
<p class="card-subtitle">Last 50 requests</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="card-action-btn" title="Refresh">
|
||||||
|
<i class="fas fa-redo"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table" id="recent-requests">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Provider</th>
|
||||||
|
<th>Model</th>
|
||||||
|
<th>Tokens</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Requests will be loaded dynamically -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAnalyticsContent() {
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title">Usage Analytics</h3>
|
||||||
|
<p class="card-subtitle">Filter and analyze usage data</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn btn-secondary">
|
||||||
|
<i class="fas fa-download"></i> Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-control">
|
||||||
|
<label>Date Range</label>
|
||||||
|
<select id="date-range">
|
||||||
|
<option value="24h">Last 24 Hours</option>
|
||||||
|
<option value="7d" selected>Last 7 Days</option>
|
||||||
|
<option value="30d">Last 30 Days</option>
|
||||||
|
<option value="custom">Custom Range</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label>Client</label>
|
||||||
|
<select id="client-filter">
|
||||||
|
<option value="all">All Clients</option>
|
||||||
|
<!-- Client options will be loaded dynamically -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label>Provider</label>
|
||||||
|
<select id="provider-filter">
|
||||||
|
<option value="all">All Providers</option>
|
||||||
|
<option value="openai">OpenAI</option>
|
||||||
|
<option value="gemini">Gemini</option>
|
||||||
|
<option value="deepseek">DeepSeek</option>
|
||||||
|
<option value="grok">Grok</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container">
|
||||||
|
<div class="chart-header">
|
||||||
|
<h3 class="chart-title">Request Trends</h3>
|
||||||
|
<div class="chart-controls">
|
||||||
|
<button class="chart-control-btn active" data-metric="requests">Requests</button>
|
||||||
|
<button class="chart-control-btn" data-metric="tokens">Tokens</button>
|
||||||
|
<button class="chart-control-btn" data-metric="cost">Cost</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<canvas id="analytics-chart" height="350"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="chart-container">
|
||||||
|
<div class="chart-header">
|
||||||
|
<h3 class="chart-title">Top Clients</h3>
|
||||||
|
</div>
|
||||||
|
<canvas id="clients-chart" height="300"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container">
|
||||||
|
<div class="chart-header">
|
||||||
|
<h3 class="chart-title">Top Models</h3>
|
||||||
|
</div>
|
||||||
|
<canvas id="models-chart" height="300"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Detailed Usage Data</h3>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table" id="usage-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Provider</th>
|
||||||
|
<th>Model</th>
|
||||||
|
<th>Requests</th>
|
||||||
|
<th>Tokens</th>
|
||||||
|
<th>Cost</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Usage data will be loaded dynamically -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCostsContent() {
|
||||||
|
return `
|
||||||
|
<div class="stats-grid" id="cost-stats">
|
||||||
|
<!-- Cost stats will be loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container">
|
||||||
|
<div class="chart-header">
|
||||||
|
<h3 class="chart-title">Cost Breakdown</h3>
|
||||||
|
<div class="chart-controls">
|
||||||
|
<button class="chart-control-btn active" data-breakdown="provider">By Provider</button>
|
||||||
|
<button class="chart-control-btn" data-breakdown="client">By Client</button>
|
||||||
|
<button class="chart-control-btn" data-breakdown="model">By Model</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<canvas id="costs-chart" height="300"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Budget Tracking</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="budget-progress">
|
||||||
|
<!-- Budget progress bars will be loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Cost Projections</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="cost-projections">
|
||||||
|
<!-- Projections will be loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title">Pricing Configuration</h3>
|
||||||
|
<p class="card-subtitle">Current provider pricing</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn btn-primary" id="edit-pricing">
|
||||||
|
<i class="fas fa-edit"></i> Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table" id="pricing-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Provider</th>
|
||||||
|
<th>Model</th>
|
||||||
|
<th>Input Price</th>
|
||||||
|
<th>Output Price</th>
|
||||||
|
<th>Last Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Pricing data will be loaded dynamically -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClientsContent() {
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title">Client Management</h3>
|
||||||
|
<p class="card-subtitle">Manage API clients and tokens</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn btn-primary" id="add-client">
|
||||||
|
<i class="fas fa-plus"></i> Add Client
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table" id="clients-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Client ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Token</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Last Used</th>
|
||||||
|
<th>Requests</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Clients will be loaded dynamically -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Client Usage Summary</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="client-usage-chart" height="250"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Rate Limit Status</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="rate-limit-status">
|
||||||
|
<!-- Rate limit status will be loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProvidersContent() {
|
||||||
|
return `
|
||||||
|
<div class="stats-grid" id="provider-stats">
|
||||||
|
<!-- Provider stats will be loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Provider Configuration</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="providers-list">
|
||||||
|
<!-- Providers will be loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Model Availability</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="models-list">
|
||||||
|
<!-- Models will be loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Connection Tests</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="connection-tests">
|
||||||
|
<!-- Test results will be loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-primary" id="test-all-providers">
|
||||||
|
<i class="fas fa-play"></i> Test All Providers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMonitoringContent() {
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title">Real-time Monitoring</h3>
|
||||||
|
<p class="card-subtitle">Live request stream and system metrics</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn btn-secondary" id="pause-monitoring">
|
||||||
|
<i class="fas fa-pause"></i> Pause
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="grid-2">
|
||||||
|
<div>
|
||||||
|
<h4>Live Request Stream</h4>
|
||||||
|
<div id="request-stream" class="monitoring-stream">
|
||||||
|
<!-- Live requests will appear here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>System Metrics</h4>
|
||||||
|
<div id="system-metrics">
|
||||||
|
<!-- System metrics will be loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-3">
|
||||||
|
<div class="chart-container">
|
||||||
|
<div class="chart-header">
|
||||||
|
<h3 class="chart-title">Response Time (ms)</h3>
|
||||||
|
</div>
|
||||||
|
<canvas id="response-time-chart" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container">
|
||||||
|
<div class="chart-header">
|
||||||
|
<h3 class="chart-title">Error Rate (%)</h3>
|
||||||
|
</div>
|
||||||
|
<canvas id="error-rate-chart" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container">
|
||||||
|
<div class="chart-header">
|
||||||
|
<h3 class="chart-title">Rate Limit Usage</h3>
|
||||||
|
</div>
|
||||||
|
<canvas id="rate-limit-chart" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">System Logs (Live)</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="system-logs" class="log-stream">
|
||||||
|
<!-- System logs will appear here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSettingsContent() {
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">System Settings</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="settings-form">
|
||||||
|
<div class="form-section">
|
||||||
|
<h4>General Configuration</h4>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-control">
|
||||||
|
<label>Server Port</label>
|
||||||
|
<input type="number" id="server-port" value="8080" min="1024" max="65535">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label>Log Level</label>
|
||||||
|
<select id="log-level">
|
||||||
|
<option value="error">Error</option>
|
||||||
|
<option value="warn">Warning</option>
|
||||||
|
<option value="info" selected>Info</option>
|
||||||
|
<option value="debug">Debug</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<h4>Database Settings</h4>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-control">
|
||||||
|
<label>Database Path</label>
|
||||||
|
<input type="text" id="db-path" value="./data/llm-proxy.db">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label>Backup Interval (hours)</label>
|
||||||
|
<input type="number" id="backup-interval" value="24" min="1" max="168">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<h4>Security Settings</h4>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-control">
|
||||||
|
<label>Dashboard Password</label>
|
||||||
|
<input type="password" id="dashboard-password" placeholder="Leave empty to keep current">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label>Session Timeout (minutes)</label>
|
||||||
|
<input type="number" id="session-timeout" value="30" min="5" max="1440">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="reset-settings">
|
||||||
|
<i class="fas fa-undo"></i> Reset
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save"></i> Save Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Database Management</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-secondary" id="backup-db">
|
||||||
|
<i class="fas fa-download"></i> Backup Database
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-warning" id="optimize-db">
|
||||||
|
<i class="fas fa-magic"></i> Optimize Database
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">System Information</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="system-info">
|
||||||
|
<!-- System info will be loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogsContent() {
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title">System Logs</h3>
|
||||||
|
<p class="card-subtitle">View and filter system logs</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn btn-secondary" id="download-logs">
|
||||||
|
<i class="fas fa-download"></i> Download
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger" id="clear-logs">
|
||||||
|
<i class="fas fa-trash"></i> Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-control">
|
||||||
|
<label>Log Level</label>
|
||||||
|
<select id="log-filter">
|
||||||
|
<option value="all">All Levels</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
<option value="warn">Warning</option>
|
||||||
|
<option value="info">Info</option>
|
||||||
|
<option value="debug">Debug</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label>Time Range</label>
|
||||||
|
<select id="log-time-range">
|
||||||
|
<option value="1h">Last Hour</option>
|
||||||
|
<option value="24h" selected>Last 24 Hours</option>
|
||||||
|
<option value="7d">Last 7 Days</option>
|
||||||
|
<option value="30d">Last 30 Days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label>Search</label>
|
||||||
|
<input type="text" id="log-search" placeholder="Search logs...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table" id="logs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>Level</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Logs will be loaded dynamically -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize dashboard when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.initDashboard = () => {
|
||||||
|
window.dashboard = new Dashboard();
|
||||||
|
};
|
||||||
|
|
||||||
|
// If already authenticated, initialize immediately
|
||||||
|
if (window.authManager && window.authManager.isAuthenticated) {
|
||||||
|
window.initDashboard();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = Dashboard;
|
||||||
|
}
|
||||||
334
static/js/pages/analytics.js
Normal file
334
static/js/pages/analytics.js
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
// Analytics Page Module
|
||||||
|
|
||||||
|
class AnalyticsPage {
|
||||||
|
constructor() {
|
||||||
|
this.filters = {
|
||||||
|
dateRange: '7d',
|
||||||
|
client: 'all',
|
||||||
|
provider: 'all'
|
||||||
|
};
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// Load initial data
|
||||||
|
await this.loadFilters();
|
||||||
|
await this.loadCharts();
|
||||||
|
await this.loadUsageData();
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadFilters() {
|
||||||
|
try {
|
||||||
|
// Load clients for filter dropdown
|
||||||
|
// In a real app, this would fetch from /api/clients
|
||||||
|
const clients = [
|
||||||
|
{ id: 'client-1', name: 'Web Application' },
|
||||||
|
{ id: 'client-2', name: 'Mobile App' },
|
||||||
|
{ id: 'client-3', name: 'API Integration' },
|
||||||
|
{ id: 'client-4', name: 'Internal Tools' },
|
||||||
|
{ id: 'client-5', name: 'Testing Suite' }
|
||||||
|
];
|
||||||
|
|
||||||
|
this.renderClientFilter(clients);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading filters:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderClientFilter(clients) {
|
||||||
|
const select = document.getElementById('client-filter');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
// Clear existing options except "All Clients"
|
||||||
|
while (select.options.length > 1) {
|
||||||
|
select.remove(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add client options
|
||||||
|
clients.forEach(client => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = client.id;
|
||||||
|
option.textContent = client.name;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCharts() {
|
||||||
|
await this.loadAnalyticsChart();
|
||||||
|
await this.loadClientsChart();
|
||||||
|
await this.loadModelsChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAnalyticsChart() {
|
||||||
|
try {
|
||||||
|
// Generate demo data
|
||||||
|
const labels = window.chartManager.generateDateLabels(7);
|
||||||
|
const data = {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Requests',
|
||||||
|
data: labels.map(() => Math.floor(Math.random() * 1000) + 500),
|
||||||
|
color: '#3b82f6',
|
||||||
|
fill: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tokens',
|
||||||
|
data: labels.map(() => Math.floor(Math.random() * 100000) + 50000),
|
||||||
|
color: '#10b981',
|
||||||
|
fill: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cost ($)',
|
||||||
|
data: labels.map(() => Math.random() * 50 + 10),
|
||||||
|
color: '#f59e0b',
|
||||||
|
fill: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create chart
|
||||||
|
window.chartManager.createLineChart('analytics-chart', data, {
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
ticks: {
|
||||||
|
callback: function(value) {
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading analytics chart:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadClientsChart() {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
labels: ['Web App', 'Mobile App', 'API Integration', 'Internal Tools', 'Testing'],
|
||||||
|
datasets: [{
|
||||||
|
label: 'Requests',
|
||||||
|
data: [45, 25, 15, 10, 5],
|
||||||
|
color: '#3b82f6'
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
window.chartManager.createHorizontalBarChart('clients-chart', data);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading clients chart:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadModelsChart() {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
labels: ['gpt-4', 'gpt-3.5-turbo', 'gemini-pro', 'deepseek-chat', 'grok-beta'],
|
||||||
|
data: [35, 30, 20, 10, 5],
|
||||||
|
colors: ['#3b82f6', '#60a5fa', '#10b981', '#f59e0b', '#8b5cf6']
|
||||||
|
};
|
||||||
|
|
||||||
|
window.chartManager.createDoughnutChart('models-chart', data);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading models chart:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadUsageData() {
|
||||||
|
try {
|
||||||
|
// In a real app, this would fetch from /api/usage/detailed
|
||||||
|
const usageData = [
|
||||||
|
{ date: '2024-01-15', client: 'Web App', provider: 'OpenAI', model: 'gpt-4', requests: 245, tokens: 125000, cost: 12.50 },
|
||||||
|
{ date: '2024-01-15', client: 'Mobile App', provider: 'Gemini', model: 'gemini-pro', requests: 180, tokens: 89000, cost: 8.90 },
|
||||||
|
{ date: '2024-01-15', client: 'API Integration', provider: 'OpenAI', model: 'gpt-3.5-turbo', requests: 320, tokens: 156000, cost: 15.60 },
|
||||||
|
{ date: '2024-01-14', client: 'Web App', provider: 'OpenAI', model: 'gpt-4', requests: 210, tokens: 110000, cost: 11.00 },
|
||||||
|
{ date: '2024-01-14', client: 'Internal Tools', provider: 'DeepSeek', model: 'deepseek-chat', requests: 95, tokens: 48000, cost: 4.80 },
|
||||||
|
{ date: '2024-01-14', client: 'Testing Suite', provider: 'Grok', model: 'grok-beta', requests: 45, tokens: 22000, cost: 2.20 },
|
||||||
|
{ date: '2024-01-13', client: 'Web App', provider: 'OpenAI', model: 'gpt-4', requests: 195, tokens: 98000, cost: 9.80 },
|
||||||
|
{ date: '2024-01-13', client: 'Mobile App', provider: 'Gemini', model: 'gemini-pro', requests: 165, tokens: 82000, cost: 8.20 },
|
||||||
|
{ date: '2024-01-13', client: 'API Integration', provider: 'OpenAI', model: 'gpt-3.5-turbo', requests: 285, tokens: 142000, cost: 14.20 },
|
||||||
|
{ date: '2024-01-12', client: 'Web App', provider: 'OpenAI', model: 'gpt-4', requests: 230, tokens: 118000, cost: 11.80 }
|
||||||
|
];
|
||||||
|
|
||||||
|
this.renderUsageTable(usageData);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading usage data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderUsageTable(data) {
|
||||||
|
const tableBody = document.querySelector('#usage-table tbody');
|
||||||
|
if (!tableBody) return;
|
||||||
|
|
||||||
|
tableBody.innerHTML = data.map(row => `
|
||||||
|
<tr>
|
||||||
|
<td>${row.date}</td>
|
||||||
|
<td>${row.client}</td>
|
||||||
|
<td>${row.provider}</td>
|
||||||
|
<td>${row.model}</td>
|
||||||
|
<td>${row.requests.toLocaleString()}</td>
|
||||||
|
<td>${row.tokens.toLocaleString()}</td>
|
||||||
|
<td>$${row.cost.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Filter controls
|
||||||
|
const dateRangeSelect = document.getElementById('date-range');
|
||||||
|
const clientSelect = document.getElementById('client-filter');
|
||||||
|
const providerSelect = document.getElementById('provider-filter');
|
||||||
|
|
||||||
|
if (dateRangeSelect) {
|
||||||
|
dateRangeSelect.addEventListener('change', (e) => {
|
||||||
|
this.filters.dateRange = e.target.value;
|
||||||
|
this.applyFilters();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientSelect) {
|
||||||
|
clientSelect.addEventListener('change', (e) => {
|
||||||
|
this.filters.client = e.target.value;
|
||||||
|
this.applyFilters();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerSelect) {
|
||||||
|
providerSelect.addEventListener('change', (e) => {
|
||||||
|
this.filters.provider = e.target.value;
|
||||||
|
this.applyFilters();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart metric buttons
|
||||||
|
const metricButtons = document.querySelectorAll('.chart-control-btn[data-metric]');
|
||||||
|
metricButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
// Update active state
|
||||||
|
metricButtons.forEach(btn => btn.classList.remove('active'));
|
||||||
|
button.classList.add('active');
|
||||||
|
|
||||||
|
// Update chart based on metric
|
||||||
|
this.updateAnalyticsChart(button.dataset.metric);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export button
|
||||||
|
const exportBtn = document.querySelector('#analytics .btn-secondary');
|
||||||
|
if (exportBtn) {
|
||||||
|
exportBtn.addEventListener('click', () => {
|
||||||
|
this.exportData();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilters() {
|
||||||
|
console.log('Applying filters:', this.filters);
|
||||||
|
// In a real app, this would fetch filtered data from the API
|
||||||
|
// For now, just show a toast
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Filters applied', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh data
|
||||||
|
this.loadCharts();
|
||||||
|
this.loadUsageData();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnalyticsChart(metric) {
|
||||||
|
// Update the main analytics chart to show the selected metric
|
||||||
|
const labels = window.chartManager.generateDateLabels(7);
|
||||||
|
|
||||||
|
let data;
|
||||||
|
if (metric === 'requests') {
|
||||||
|
data = {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Requests',
|
||||||
|
data: labels.map(() => Math.floor(Math.random() * 1000) + 500),
|
||||||
|
color: '#3b82f6',
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} else if (metric === 'tokens') {
|
||||||
|
data = {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Tokens',
|
||||||
|
data: labels.map(() => Math.floor(Math.random() * 100000) + 50000),
|
||||||
|
color: '#10b981',
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} else if (metric === 'cost') {
|
||||||
|
data = {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Cost ($)',
|
||||||
|
data: labels.map(() => Math.random() * 50 + 10),
|
||||||
|
color: '#f59e0b',
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.chartManager.updateChartData('analytics-chart', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
exportData() {
|
||||||
|
// Create CSV data
|
||||||
|
const table = document.getElementById('usage-table');
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
const rows = table.querySelectorAll('tr');
|
||||||
|
const csv = [];
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const rowData = [];
|
||||||
|
row.querySelectorAll('th, td').forEach(cell => {
|
||||||
|
rowData.push(`"${cell.textContent.replace(/"/g, '""')}"`);
|
||||||
|
});
|
||||||
|
csv.push(rowData.join(','));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create download link
|
||||||
|
const blob = new Blob([csv.join('\n')], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `llm-proxy-analytics-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Data exported successfully', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this.loadCharts();
|
||||||
|
this.loadUsageData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize analytics page when needed
|
||||||
|
window.initAnalytics = async () => {
|
||||||
|
window.analyticsPage = new AnalyticsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = AnalyticsPage;
|
||||||
|
}
|
||||||
471
static/js/pages/clients.js
Normal file
471
static/js/pages/clients.js
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
// Clients Page Module
|
||||||
|
|
||||||
|
class ClientsPage {
|
||||||
|
constructor() {
|
||||||
|
this.clients = [];
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// Load data
|
||||||
|
await this.loadClients();
|
||||||
|
await this.loadClientUsageChart();
|
||||||
|
await this.loadRateLimitStatus();
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadClients() {
|
||||||
|
try {
|
||||||
|
// In a real app, this would fetch from /api/clients
|
||||||
|
this.clients = [
|
||||||
|
{ id: 'client-1', name: 'Web Application', token: 'sk-*****abc123', created: '2024-01-01', lastUsed: '2024-01-15', requests: 1245, status: 'active' },
|
||||||
|
{ id: 'client-2', name: 'Mobile App', token: 'sk-*****def456', created: '2024-01-05', lastUsed: '2024-01-15', requests: 890, status: 'active' },
|
||||||
|
{ id: 'client-3', name: 'API Integration', token: 'sk-*****ghi789', created: '2024-01-08', lastUsed: '2024-01-14', requests: 1560, status: 'active' },
|
||||||
|
{ id: 'client-4', name: 'Internal Tools', token: 'sk-*****jkl012', created: '2024-01-10', lastUsed: '2024-01-13', requests: 340, status: 'inactive' },
|
||||||
|
{ id: 'client-5', name: 'Testing Suite', token: 'sk-*****mno345', created: '2024-01-12', lastUsed: '2024-01-12', requests: 120, status: 'active' },
|
||||||
|
{ id: 'client-6', name: 'Backup Service', token: 'sk-*****pqr678', created: '2024-01-14', lastUsed: null, requests: 0, status: 'pending' }
|
||||||
|
];
|
||||||
|
|
||||||
|
this.renderClientsTable();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading clients:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderClientsTable() {
|
||||||
|
const tableBody = document.querySelector('#clients-table tbody');
|
||||||
|
if (!tableBody) return;
|
||||||
|
|
||||||
|
tableBody.innerHTML = this.clients.map(client => {
|
||||||
|
const statusClass = client.status === 'active' ? 'success' :
|
||||||
|
client.status === 'inactive' ? 'warning' : 'secondary';
|
||||||
|
const statusIcon = client.status === 'active' ? 'check-circle' :
|
||||||
|
client.status === 'inactive' ? 'exclamation-triangle' : 'clock';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${client.id}</td>
|
||||||
|
<td>${client.name}</td>
|
||||||
|
<td>
|
||||||
|
<code class="token-display">${client.token}</code>
|
||||||
|
<button class="btn-copy-token" data-token="${client.token}" title="Copy token">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>${client.created}</td>
|
||||||
|
<td>${client.lastUsed || 'Never'}</td>
|
||||||
|
<td>${client.requests.toLocaleString()}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge ${statusClass}">
|
||||||
|
<i class="fas fa-${statusIcon}"></i>
|
||||||
|
${client.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button class="btn-action" title="Edit" data-action="edit" data-id="${client.id}">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn-action" title="Rotate Token" data-action="rotate" data-id="${client.id}">
|
||||||
|
<i class="fas fa-redo"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn-action danger" title="Revoke" data-action="revoke" data-id="${client.id}">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Add CSS for action buttons
|
||||||
|
this.addActionStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
addActionStyles() {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.token-display {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy-token {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy-token:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action.danger:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadClientUsageChart() {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
labels: ['Web App', 'Mobile App', 'API Integration', 'Internal Tools', 'Testing'],
|
||||||
|
datasets: [{
|
||||||
|
label: 'Requests',
|
||||||
|
data: [1245, 890, 1560, 340, 120],
|
||||||
|
color: '#3b82f6'
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
window.chartManager.createHorizontalBarChart('client-usage-chart', data);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading client usage chart:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadRateLimitStatus() {
|
||||||
|
const container = document.getElementById('rate-limit-status');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const rateLimits = [
|
||||||
|
{ client: 'Web Application', limit: 1000, used: 645, remaining: 355 },
|
||||||
|
{ client: 'Mobile App', limit: 500, used: 320, remaining: 180 },
|
||||||
|
{ client: 'API Integration', limit: 2000, used: 1560, remaining: 440 },
|
||||||
|
{ client: 'Internal Tools', limit: 100, used: 34, remaining: 66 },
|
||||||
|
{ client: 'Testing Suite', limit: 200, used: 120, remaining: 80 }
|
||||||
|
];
|
||||||
|
|
||||||
|
container.innerHTML = rateLimits.map(limit => {
|
||||||
|
const percentage = (limit.used / limit.limit) * 100;
|
||||||
|
let color = 'success';
|
||||||
|
if (percentage > 80) color = 'warning';
|
||||||
|
if (percentage > 95) color = 'danger';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="rate-limit-item">
|
||||||
|
<div class="rate-limit-header">
|
||||||
|
<span class="rate-limit-client">${limit.client}</span>
|
||||||
|
<span class="rate-limit-numbers">${limit.used} / ${limit.limit}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill ${color}" style="width: ${percentage}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="rate-limit-footer">
|
||||||
|
<span class="rate-limit-percentage">${Math.round(percentage)}% used</span>
|
||||||
|
<span class="rate-limit-remaining">${limit.remaining} remaining</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Add CSS for rate limit items
|
||||||
|
this.addRateLimitStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
addRateLimitStyles() {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.rate-limit-item {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-limit-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-limit-client {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-limit-numbers {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-limit-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-limit-percentage {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-limit-remaining {
|
||||||
|
color: var(--success);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Add client button
|
||||||
|
const addBtn = document.getElementById('add-client');
|
||||||
|
if (addBtn) {
|
||||||
|
addBtn.addEventListener('click', () => {
|
||||||
|
this.showAddClientModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy token buttons
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.btn-copy-token')) {
|
||||||
|
const button = e.target.closest('.btn-copy-token');
|
||||||
|
const token = button.dataset.token;
|
||||||
|
this.copyToClipboard(token);
|
||||||
|
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Token copied to clipboard', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.btn-action')) {
|
||||||
|
const button = e.target.closest('.btn-action');
|
||||||
|
const action = button.dataset.action;
|
||||||
|
const clientId = button.dataset.id;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'edit':
|
||||||
|
this.editClient(clientId);
|
||||||
|
break;
|
||||||
|
case 'rotate':
|
||||||
|
this.rotateToken(clientId);
|
||||||
|
break;
|
||||||
|
case 'revoke':
|
||||||
|
this.revokeClient(clientId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showAddClientModal() {
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'modal active';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Add New Client</h3>
|
||||||
|
<button class="modal-close">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="add-client-form">
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="client-name">Client Name</label>
|
||||||
|
<input type="text" id="client-name" placeholder="e.g., Web Application" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="client-description">Description (Optional)</label>
|
||||||
|
<textarea id="client-description" rows="3" placeholder="Describe what this client will be used for..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="rate-limit">Rate Limit (requests per hour)</label>
|
||||||
|
<input type="number" id="rate-limit" value="1000" min="1" max="10000">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary close-modal">Cancel</button>
|
||||||
|
<button class="btn btn-primary create-client">Create Client</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
const closeBtn = modal.querySelector('.modal-close');
|
||||||
|
const closeModalBtn = modal.querySelector('.close-modal');
|
||||||
|
const createBtn = modal.querySelector('.create-client');
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
setTimeout(() => modal.remove(), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
closeBtn.addEventListener('click', closeModal);
|
||||||
|
closeModalBtn.addEventListener('click', closeModal);
|
||||||
|
|
||||||
|
createBtn.addEventListener('click', () => {
|
||||||
|
const name = modal.querySelector('#client-name').value;
|
||||||
|
if (!name.trim()) {
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Client name is required', 'error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a real app, this would create the client via API
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast(`Client "${name}" created successfully`, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh clients list
|
||||||
|
this.loadClients();
|
||||||
|
closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on background click
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
editClient(clientId) {
|
||||||
|
const client = this.clients.find(c => c.id === clientId);
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
// Show edit modal
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'modal active';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Edit Client: ${client.name}</h3>
|
||||||
|
<button class="modal-close">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Client editing would be implemented here.</p>
|
||||||
|
<p>In a real implementation, this would include forms for updating client settings.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary close-modal">Cancel</button>
|
||||||
|
<button class="btn btn-primary save-client">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
const closeBtn = modal.querySelector('.modal-close');
|
||||||
|
const closeModalBtn = modal.querySelector('.close-modal');
|
||||||
|
const saveBtn = modal.querySelector('.save-client');
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
setTimeout(() => modal.remove(), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
closeBtn.addEventListener('click', closeModal);
|
||||||
|
closeModalBtn.addEventListener('click', closeModal);
|
||||||
|
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
// In a real app, this would save client changes
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Client updated successfully', 'success');
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on background click
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rotateToken(clientId) {
|
||||||
|
const client = this.clients.find(c => c.id === clientId);
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
// Show confirmation modal
|
||||||
|
if (confirm(`Are you sure you want to rotate the token for "${client.name}"? The old token will be invalidated.`)) {
|
||||||
|
// In a real app, this would rotate the token via API
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast(`Token rotated for "${client.name}"`, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh clients list
|
||||||
|
this.loadClients();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeClient(clientId) {
|
||||||
|
const client = this.clients.find(c => c.id === clientId);
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
// Show confirmation modal
|
||||||
|
if (confirm(`Are you sure you want to revoke client "${client.name}"? This action cannot be undone.`)) {
|
||||||
|
// In a real app, this would revoke the client via API
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast(`Client "${client.name}" revoked`, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh clients list
|
||||||
|
this.loadClients();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copyToClipboard(text) {
|
||||||
|
navigator.clipboard.writeText(text).catch(err => {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this.loadClients();
|
||||||
|
this.loadClientUsageChart();
|
||||||
|
this.loadRateLimitStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize clients page when needed
|
||||||
|
window.initClients = async () => {
|
||||||
|
window.clientsPage = new ClientsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = ClientsPage;
|
||||||
|
}
|
||||||
468
static/js/pages/costs.js
Normal file
468
static/js/pages/costs.js
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
// Costs Page Module
|
||||||
|
|
||||||
|
class CostsPage {
|
||||||
|
constructor() {
|
||||||
|
this.costData = null;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// Load data
|
||||||
|
await this.loadCostStats();
|
||||||
|
await this.loadCostsChart();
|
||||||
|
await this.loadBudgetTracking();
|
||||||
|
await this.loadCostProjections();
|
||||||
|
await this.loadPricingTable();
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCostStats() {
|
||||||
|
try {
|
||||||
|
// In a real app, this would fetch from /api/costs/summary
|
||||||
|
this.costData = {
|
||||||
|
totalCost: 125.43,
|
||||||
|
todayCost: 12.45,
|
||||||
|
weekCost: 45.67,
|
||||||
|
monthCost: 125.43,
|
||||||
|
avgDailyCost: 8.36,
|
||||||
|
costTrend: 5.2, // percentage
|
||||||
|
budgetUsed: 62, // percentage
|
||||||
|
projectedMonthEnd: 189.75
|
||||||
|
};
|
||||||
|
|
||||||
|
this.renderCostStats();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading cost stats:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCostStats() {
|
||||||
|
const container = document.getElementById('cost-stats');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon warning">
|
||||||
|
<i class="fas fa-dollar-sign"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">$${this.costData.totalCost.toFixed(2)}</div>
|
||||||
|
<div class="stat-label">Total Cost</div>
|
||||||
|
<div class="stat-change positive">
|
||||||
|
<i class="fas fa-arrow-up"></i>
|
||||||
|
$${this.costData.todayCost.toFixed(2)} today
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon primary">
|
||||||
|
<i class="fas fa-calendar-week"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">$${this.costData.weekCost.toFixed(2)}</div>
|
||||||
|
<div class="stat-label">This Week</div>
|
||||||
|
<div class="stat-change ${this.costData.costTrend > 0 ? 'positive' : 'negative'}">
|
||||||
|
<i class="fas fa-arrow-${this.costData.costTrend > 0 ? 'up' : 'down'}"></i>
|
||||||
|
${Math.abs(this.costData.costTrend)}% from last week
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon success">
|
||||||
|
<i class="fas fa-calendar-alt"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">$${this.costData.monthCost.toFixed(2)}</div>
|
||||||
|
<div class="stat-label">This Month</div>
|
||||||
|
<div class="stat-change">
|
||||||
|
<i class="fas fa-chart-line"></i>
|
||||||
|
$${this.costData.avgDailyCost.toFixed(2)}/day avg
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon danger">
|
||||||
|
<i class="fas fa-piggy-bank"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">${this.costData.budgetUsed}%</div>
|
||||||
|
<div class="stat-label">Budget Used</div>
|
||||||
|
<div class="stat-change">
|
||||||
|
<i class="fas fa-project-diagram"></i>
|
||||||
|
$${this.costData.projectedMonthEnd.toFixed(2)} projected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCostsChart() {
|
||||||
|
try {
|
||||||
|
// Generate demo data
|
||||||
|
const data = {
|
||||||
|
labels: ['OpenAI', 'Gemini', 'DeepSeek', 'Grok'],
|
||||||
|
datasets: [{
|
||||||
|
label: 'Cost by Provider',
|
||||||
|
data: [65, 25, 8, 2],
|
||||||
|
color: '#3b82f6'
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
window.chartManager.createBarChart('costs-chart', data, {
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
return `$${context.parsed.y.toFixed(2)} (${context.parsed.y}%)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading costs chart:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadBudgetTracking() {
|
||||||
|
const container = document.getElementById('budget-progress');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const budgets = [
|
||||||
|
{ name: 'Monthly Budget', used: 62, total: 200, color: 'primary' },
|
||||||
|
{ name: 'OpenAI Budget', used: 75, total: 150, color: 'info' },
|
||||||
|
{ name: 'Gemini Budget', used: 45, total: 100, color: 'success' },
|
||||||
|
{ name: 'Team Budget', used: 30, total: 50, color: 'warning' }
|
||||||
|
];
|
||||||
|
|
||||||
|
container.innerHTML = budgets.map(budget => `
|
||||||
|
<div class="budget-item">
|
||||||
|
<div class="budget-header">
|
||||||
|
<span class="budget-name">${budget.name}</span>
|
||||||
|
<span class="budget-amount">$${budget.used} / $${budget.total}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill ${budget.color}" style="width: ${(budget.used / budget.total) * 100}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="budget-footer">
|
||||||
|
<span class="budget-percentage">${Math.round((budget.used / budget.total) * 100)}% used</span>
|
||||||
|
<span class="budget-remaining">$${budget.total - budget.used} remaining</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Add CSS for budget items
|
||||||
|
this.addBudgetStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
addBudgetStyles() {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.budget-item {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-amount {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-percentage {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-remaining {
|
||||||
|
color: var(--success);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill.primary {
|
||||||
|
background-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill.info {
|
||||||
|
background-color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill.success {
|
||||||
|
background-color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill.warning {
|
||||||
|
background-color: var(--warning);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCostProjections() {
|
||||||
|
const container = document.getElementById('cost-projections');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const projections = [
|
||||||
|
{ period: 'Today', amount: 12.45, trend: 'up' },
|
||||||
|
{ period: 'This Week', amount: 45.67, trend: 'up' },
|
||||||
|
{ period: 'This Month', amount: 189.75, trend: 'up' },
|
||||||
|
{ period: 'Next Month', amount: 210.50, trend: 'up' }
|
||||||
|
];
|
||||||
|
|
||||||
|
container.innerHTML = projections.map(proj => `
|
||||||
|
<div class="projection-item">
|
||||||
|
<div class="projection-period">${proj.period}</div>
|
||||||
|
<div class="projection-amount">$${proj.amount.toFixed(2)}</div>
|
||||||
|
<div class="projection-trend ${proj.trend}">
|
||||||
|
<i class="fas fa-arrow-${proj.trend}"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Add CSS for projections
|
||||||
|
this.addProjectionStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
addProjectionStyles() {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.projection-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projection-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projection-period {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projection-amount {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projection-trend {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projection-trend.up {
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projection-trend.down {
|
||||||
|
background-color: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPricingTable() {
|
||||||
|
try {
|
||||||
|
// In a real app, this would fetch from /api/pricing
|
||||||
|
const pricingData = [
|
||||||
|
{ provider: 'OpenAI', model: 'gpt-4', input: 0.03, output: 0.06, updated: '2024-01-15' },
|
||||||
|
{ provider: 'OpenAI', model: 'gpt-3.5-turbo', input: 0.0015, output: 0.002, updated: '2024-01-15' },
|
||||||
|
{ provider: 'Gemini', model: 'gemini-pro', input: 0.0005, output: 0.0015, updated: '2024-01-14' },
|
||||||
|
{ provider: 'Gemini', model: 'gemini-pro-vision', input: 0.0025, output: 0.0075, updated: '2024-01-14' },
|
||||||
|
{ provider: 'DeepSeek', model: 'deepseek-chat', input: 0.00014, output: 0.00028, updated: '2024-01-13' },
|
||||||
|
{ provider: 'DeepSeek', model: 'deepseek-coder', input: 0.00014, output: 0.00028, updated: '2024-01-13' },
|
||||||
|
{ provider: 'Grok', model: 'grok-beta', input: 0.01, output: 0.03, updated: '2024-01-12' }
|
||||||
|
];
|
||||||
|
|
||||||
|
this.renderPricingTable(pricingData);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading pricing data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPricingTable(data) {
|
||||||
|
const tableBody = document.querySelector('#pricing-table tbody');
|
||||||
|
if (!tableBody) return;
|
||||||
|
|
||||||
|
tableBody.innerHTML = data.map(row => `
|
||||||
|
<tr>
|
||||||
|
<td>${row.provider}</td>
|
||||||
|
<td>${row.model}</td>
|
||||||
|
<td>$${row.input.toFixed(5)}/1K tokens</td>
|
||||||
|
<td>$${row.output.toFixed(5)}/1K tokens</td>
|
||||||
|
<td>${row.updated}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Breakdown buttons
|
||||||
|
const breakdownButtons = document.querySelectorAll('.chart-control-btn[data-breakdown]');
|
||||||
|
breakdownButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
// Update active state
|
||||||
|
breakdownButtons.forEach(btn => btn.classList.remove('active'));
|
||||||
|
button.classList.add('active');
|
||||||
|
|
||||||
|
// Update chart based on breakdown
|
||||||
|
this.updateCostsChart(button.dataset.breakdown);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit pricing button
|
||||||
|
const editBtn = document.getElementById('edit-pricing');
|
||||||
|
if (editBtn) {
|
||||||
|
editBtn.addEventListener('click', () => {
|
||||||
|
this.editPricing();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCostsChart(breakdown) {
|
||||||
|
let data;
|
||||||
|
|
||||||
|
if (breakdown === 'provider') {
|
||||||
|
data = {
|
||||||
|
labels: ['OpenAI', 'Gemini', 'DeepSeek', 'Grok'],
|
||||||
|
datasets: [{
|
||||||
|
label: 'Cost by Provider',
|
||||||
|
data: [65, 25, 8, 2],
|
||||||
|
color: '#3b82f6'
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} else if (breakdown === 'client') {
|
||||||
|
data = {
|
||||||
|
labels: ['Web App', 'Mobile App', 'API Integration', 'Internal Tools', 'Testing'],
|
||||||
|
datasets: [{
|
||||||
|
label: 'Cost by Client',
|
||||||
|
data: [40, 25, 20, 10, 5],
|
||||||
|
color: '#10b981'
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} else if (breakdown === 'model') {
|
||||||
|
data = {
|
||||||
|
labels: ['gpt-4', 'gpt-3.5-turbo', 'gemini-pro', 'deepseek-chat', 'grok-beta'],
|
||||||
|
datasets: [{
|
||||||
|
label: 'Cost by Model',
|
||||||
|
data: [35, 30, 20, 10, 5],
|
||||||
|
color: '#f59e0b'
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.chartManager.updateChartData('costs-chart', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
editPricing() {
|
||||||
|
// Show pricing edit modal
|
||||||
|
this.showPricingModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
showPricingModal() {
|
||||||
|
// Create modal for editing pricing
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'modal active';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Edit Pricing Configuration</h3>
|
||||||
|
<button class="modal-close">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Pricing configuration would be editable here.</p>
|
||||||
|
<p>In a real implementation, this would include forms for updating provider pricing.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary close-modal">Cancel</button>
|
||||||
|
<button class="btn btn-primary save-pricing">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
const closeBtn = modal.querySelector('.modal-close');
|
||||||
|
const closeModalBtn = modal.querySelector('.close-modal');
|
||||||
|
const saveBtn = modal.querySelector('.save-pricing');
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
setTimeout(() => modal.remove(), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
closeBtn.addEventListener('click', closeModal);
|
||||||
|
closeModalBtn.addEventListener('click', closeModal);
|
||||||
|
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
// In a real app, this would save pricing changes
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Pricing updated successfully', 'success');
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on background click
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this.loadCostStats();
|
||||||
|
this.loadCostsChart();
|
||||||
|
this.loadBudgetTracking();
|
||||||
|
this.loadCostProjections();
|
||||||
|
this.loadPricingTable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize costs page when needed
|
||||||
|
window.initCosts = async () => {
|
||||||
|
window.costsPage = new CostsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = CostsPage;
|
||||||
|
}
|
||||||
567
static/js/pages/logs.js
Normal file
567
static/js/pages/logs.js
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
// Logs Page Module
|
||||||
|
|
||||||
|
class LogsPage {
|
||||||
|
constructor() {
|
||||||
|
this.logs = [];
|
||||||
|
this.filters = {
|
||||||
|
level: 'all',
|
||||||
|
timeRange: '24h',
|
||||||
|
search: ''
|
||||||
|
};
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// Load logs
|
||||||
|
await this.loadLogs();
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
this.setupEventListeners();
|
||||||
|
|
||||||
|
// Setup WebSocket subscription for live logs
|
||||||
|
this.setupWebSocketSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadLogs() {
|
||||||
|
try {
|
||||||
|
// In a real app, this would fetch from /api/system/logs
|
||||||
|
// Generate demo logs
|
||||||
|
this.generateDemoLogs(50);
|
||||||
|
|
||||||
|
this.applyFiltersAndRender();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading logs:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generateDemoLogs(count) {
|
||||||
|
const levels = ['info', 'warn', 'error', 'debug'];
|
||||||
|
const sources = ['server', 'database', 'auth', 'providers', 'clients', 'api'];
|
||||||
|
const messages = [
|
||||||
|
'Request processed successfully',
|
||||||
|
'Cache hit for model gpt-4',
|
||||||
|
'Rate limit check passed',
|
||||||
|
'High latency detected for DeepSeek provider',
|
||||||
|
'API key validation failed',
|
||||||
|
'Database connection pool healthy',
|
||||||
|
'New client registered: client-7',
|
||||||
|
'Backup completed successfully',
|
||||||
|
'Memory usage above 80% threshold',
|
||||||
|
'Provider Grok is offline',
|
||||||
|
'WebSocket connection established',
|
||||||
|
'Authentication token expired',
|
||||||
|
'Cost calculation completed',
|
||||||
|
'Rate limit exceeded for client-2',
|
||||||
|
'Database query optimization needed',
|
||||||
|
'SSL certificate renewed',
|
||||||
|
'System health check passed',
|
||||||
|
'Error in OpenAI API response',
|
||||||
|
'Gemini provider rate limited',
|
||||||
|
'DeepSeek connection timeout'
|
||||||
|
];
|
||||||
|
|
||||||
|
this.logs = [];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const level = levels[Math.floor(Math.random() * levels.length)];
|
||||||
|
const source = sources[Math.floor(Math.random() * sources.length)];
|
||||||
|
const message = messages[Math.floor(Math.random() * messages.length)];
|
||||||
|
|
||||||
|
// Generate timestamp (spread over last 24 hours)
|
||||||
|
const hoursAgo = Math.random() * 24;
|
||||||
|
const timestamp = new Date(now - hoursAgo * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
this.logs.push({
|
||||||
|
id: `log-${i}`,
|
||||||
|
timestamp: timestamp.toISOString(),
|
||||||
|
level: level,
|
||||||
|
source: source,
|
||||||
|
message: message,
|
||||||
|
details: level === 'error' ? 'Additional error details would appear here' : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by timestamp (newest first)
|
||||||
|
this.logs.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFiltersAndRender() {
|
||||||
|
let filteredLogs = [...this.logs];
|
||||||
|
|
||||||
|
// Apply level filter
|
||||||
|
if (this.filters.level !== 'all') {
|
||||||
|
filteredLogs = filteredLogs.filter(log => log.level === this.filters.level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply time range filter
|
||||||
|
const now = Date.now();
|
||||||
|
let timeLimit = now;
|
||||||
|
|
||||||
|
switch (this.filters.timeRange) {
|
||||||
|
case '1h':
|
||||||
|
timeLimit = now - 60 * 60 * 1000;
|
||||||
|
break;
|
||||||
|
case '24h':
|
||||||
|
timeLimit = now - 24 * 60 * 60 * 1000;
|
||||||
|
break;
|
||||||
|
case '7d':
|
||||||
|
timeLimit = now - 7 * 24 * 60 * 60 * 1000;
|
||||||
|
break;
|
||||||
|
case '30d':
|
||||||
|
timeLimit = now - 30 * 24 * 60 * 60 * 1000;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredLogs = filteredLogs.filter(log => new Date(log.timestamp) >= timeLimit);
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (this.filters.search) {
|
||||||
|
const searchLower = this.filters.search.toLowerCase();
|
||||||
|
filteredLogs = filteredLogs.filter(log =>
|
||||||
|
log.message.toLowerCase().includes(searchLower) ||
|
||||||
|
log.source.toLowerCase().includes(searchLower) ||
|
||||||
|
log.level.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderLogsTable(filteredLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLogsTable(logs) {
|
||||||
|
const tableBody = document.querySelector('#logs-table tbody');
|
||||||
|
if (!tableBody) return;
|
||||||
|
|
||||||
|
if (logs.length === 0) {
|
||||||
|
tableBody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="empty-table">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
<div>No logs found matching your filters</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tableBody.innerHTML = logs.map(log => {
|
||||||
|
const time = new Date(log.timestamp).toLocaleString();
|
||||||
|
const levelClass = `log-${log.level}`;
|
||||||
|
const levelIcon = this.getLevelIcon(log.level);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="log-row ${levelClass}" data-log-id="${log.id}">
|
||||||
|
<td>${time}</td>
|
||||||
|
<td>
|
||||||
|
<span class="log-level-badge ${levelClass}">
|
||||||
|
<i class="fas fa-${levelIcon}"></i>
|
||||||
|
${log.level.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>${log.source}</td>
|
||||||
|
<td>
|
||||||
|
<div class="log-message">${log.message}</div>
|
||||||
|
${log.details ? `<div class="log-details">${log.details}</div>` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Add CSS for logs table
|
||||||
|
this.addLogsStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
getLevelIcon(level) {
|
||||||
|
switch (level) {
|
||||||
|
case 'error': return 'exclamation-circle';
|
||||||
|
case 'warn': return 'exclamation-triangle';
|
||||||
|
case 'info': return 'info-circle';
|
||||||
|
case 'debug': return 'bug';
|
||||||
|
default: return 'circle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addLogsStyles() {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.log-level-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-error .log-level-badge {
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-warn .log-level-badge {
|
||||||
|
background-color: rgba(245, 158, 11, 0.1);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-info .log-level-badge {
|
||||||
|
background-color: rgba(6, 182, 212, 0.1);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-debug .log-level-badge {
|
||||||
|
background-color: rgba(100, 116, 139, 0.1);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-message {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-details {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row:hover {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-table {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem !important;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-table i {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-table div {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Filter controls
|
||||||
|
const logFilter = document.getElementById('log-filter');
|
||||||
|
const timeRangeFilter = document.getElementById('log-time-range');
|
||||||
|
const searchInput = document.getElementById('log-search');
|
||||||
|
|
||||||
|
if (logFilter) {
|
||||||
|
logFilter.addEventListener('change', (e) => {
|
||||||
|
this.filters.level = e.target.value;
|
||||||
|
this.applyFiltersAndRender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeRangeFilter) {
|
||||||
|
timeRangeFilter.addEventListener('change', (e) => {
|
||||||
|
this.filters.timeRange = e.target.value;
|
||||||
|
this.applyFiltersAndRender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchInput) {
|
||||||
|
let searchTimeout;
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
this.filters.search = e.target.value;
|
||||||
|
this.applyFiltersAndRender();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
const downloadBtn = document.getElementById('download-logs');
|
||||||
|
const clearBtn = document.getElementById('clear-logs');
|
||||||
|
|
||||||
|
if (downloadBtn) {
|
||||||
|
downloadBtn.addEventListener('click', () => {
|
||||||
|
this.downloadLogs();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
this.clearLogs();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log row click for details
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const logRow = e.target.closest('.log-row');
|
||||||
|
if (logRow) {
|
||||||
|
this.showLogDetails(logRow.dataset.logId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupWebSocketSubscription() {
|
||||||
|
if (!window.wsManager) return;
|
||||||
|
|
||||||
|
// Subscribe to log updates
|
||||||
|
window.wsManager.subscribe('logs', (log) => {
|
||||||
|
this.addNewLog(log);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addNewLog(log) {
|
||||||
|
// Add to beginning of logs array
|
||||||
|
this.logs.unshift({
|
||||||
|
id: `log-${Date.now()}`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level: log.level || 'info',
|
||||||
|
source: log.source || 'unknown',
|
||||||
|
message: log.message || '',
|
||||||
|
details: log.details || null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep logs array manageable
|
||||||
|
if (this.logs.length > 1000) {
|
||||||
|
this.logs = this.logs.slice(0, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters and re-render
|
||||||
|
this.applyFiltersAndRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadLogs() {
|
||||||
|
// Get filtered logs
|
||||||
|
let filteredLogs = [...this.logs];
|
||||||
|
|
||||||
|
// Apply current filters
|
||||||
|
if (this.filters.level !== 'all') {
|
||||||
|
filteredLogs = filteredLogs.filter(log => log.level === this.filters.level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create CSV content
|
||||||
|
const headers = ['Timestamp', 'Level', 'Source', 'Message', 'Details'];
|
||||||
|
const rows = filteredLogs.map(log => [
|
||||||
|
new Date(log.timestamp).toISOString(),
|
||||||
|
log.level,
|
||||||
|
log.source,
|
||||||
|
`"${log.message.replace(/"/g, '""')}"`,
|
||||||
|
log.details ? `"${log.details.replace(/"/g, '""')}"` : ''
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
...rows.map(row => row.join(','))
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// Create download link
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `llm-proxy-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Logs downloaded successfully', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLogs() {
|
||||||
|
if (confirm('Are you sure you want to clear all logs? This action cannot be undone.')) {
|
||||||
|
// In a real app, this would clear logs via API
|
||||||
|
this.logs = [];
|
||||||
|
this.applyFiltersAndRender();
|
||||||
|
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Logs cleared successfully', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showLogDetails(logId) {
|
||||||
|
const log = this.logs.find(l => l.id === logId);
|
||||||
|
if (!log) return;
|
||||||
|
|
||||||
|
// Show log details modal
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'modal active';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content" style="max-width: 800px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Log Details</h3>
|
||||||
|
<button class="modal-close">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="log-detail-grid">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Timestamp:</span>
|
||||||
|
<span class="detail-value">${new Date(log.timestamp).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Level:</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
<span class="log-level-badge log-${log.level}">
|
||||||
|
<i class="fas fa-${this.getLevelIcon(log.level)}"></i>
|
||||||
|
${log.level.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Source:</span>
|
||||||
|
<span class="detail-value">${log.source}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item full-width">
|
||||||
|
<span class="detail-label">Message:</span>
|
||||||
|
<div class="detail-value message-box">${log.message}</div>
|
||||||
|
</div>
|
||||||
|
${log.details ? `
|
||||||
|
<div class="detail-item full-width">
|
||||||
|
<span class="detail-label">Details:</span>
|
||||||
|
<div class="detail-value details-box">${log.details}</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
<div class="detail-item full-width">
|
||||||
|
<span class="detail-label">Raw JSON:</span>
|
||||||
|
<pre class="detail-value json-box">${JSON.stringify(log, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary close-modal">Close</button>
|
||||||
|
<button class="btn btn-primary copy-json" data-json='${JSON.stringify(log)}'>
|
||||||
|
<i class="fas fa-copy"></i> Copy JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
const closeBtn = modal.querySelector('.modal-close');
|
||||||
|
const closeModalBtn = modal.querySelector('.close-modal');
|
||||||
|
const copyBtn = modal.querySelector('.copy-json');
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
setTimeout(() => modal.remove(), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
closeBtn.addEventListener('click', closeModal);
|
||||||
|
closeModalBtn.addEventListener('click', closeModal);
|
||||||
|
|
||||||
|
copyBtn.addEventListener('click', () => {
|
||||||
|
const json = copyBtn.dataset.json;
|
||||||
|
navigator.clipboard.writeText(json).then(() => {
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('JSON copied to clipboard', 'success');
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on background click
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add CSS for log details
|
||||||
|
this.addLogDetailStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
addLogDetailStyles() {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.log-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-box {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-box {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid var(--warning);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-box {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 300px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this.loadLogs();
|
||||||
|
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Logs refreshed', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize logs page when needed
|
||||||
|
window.initLogs = async () => {
|
||||||
|
window.logsPage = new LogsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = LogsPage;
|
||||||
|
}
|
||||||
611
static/js/pages/monitoring.js
Normal file
611
static/js/pages/monitoring.js
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
// Monitoring Page Module
|
||||||
|
|
||||||
|
class MonitoringPage {
|
||||||
|
constructor() {
|
||||||
|
this.isPaused = false;
|
||||||
|
this.requestStream = [];
|
||||||
|
this.systemLogs = [];
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// Load initial data
|
||||||
|
await this.loadSystemMetrics();
|
||||||
|
await this.loadCharts();
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
this.setupEventListeners();
|
||||||
|
|
||||||
|
// Setup WebSocket subscriptions
|
||||||
|
this.setupWebSocketSubscriptions();
|
||||||
|
|
||||||
|
// Start simulated updates for demo
|
||||||
|
this.startDemoUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSystemMetrics() {
|
||||||
|
const container = document.getElementById('system-metrics');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const metrics = [
|
||||||
|
{ label: 'CPU Usage', value: '24%', trend: 'down', color: 'success' },
|
||||||
|
{ label: 'Memory Usage', value: '1.8 GB', trend: 'stable', color: 'warning' },
|
||||||
|
{ label: 'Disk I/O', value: '45 MB/s', trend: 'up', color: 'primary' },
|
||||||
|
{ label: 'Network', value: '125 KB/s', trend: 'up', color: 'info' },
|
||||||
|
{ label: 'Active Connections', value: '42', trend: 'stable', color: 'success' },
|
||||||
|
{ label: 'Queue Length', value: '3', trend: 'down', color: 'success' }
|
||||||
|
];
|
||||||
|
|
||||||
|
container.innerHTML = metrics.map(metric => `
|
||||||
|
<div class="metric-item">
|
||||||
|
<div class="metric-label">${metric.label}</div>
|
||||||
|
<div class="metric-value">${metric.value}</div>
|
||||||
|
<div class="metric-trend ${metric.trend}">
|
||||||
|
<i class="fas fa-arrow-${metric.trend === 'up' ? 'up' : metric.trend === 'down' ? 'down' : 'minus'}"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Add CSS for metrics
|
||||||
|
this.addMetricStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
addMetricStyles() {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.metric-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-trend {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-trend.up {
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-trend.down {
|
||||||
|
background-color: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-trend.stable {
|
||||||
|
background-color: rgba(100, 116, 139, 0.1);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitoring-stream {
|
||||||
|
height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-entry:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-entry.highlight {
|
||||||
|
background-color: rgba(37, 99, 235, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-entry-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-entry-icon {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-entry-content {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-entry-details {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-stream {
|
||||||
|
height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
color: var(--text-light);
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-level {
|
||||||
|
width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-info .log-level {
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-warn .log-level {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-error .log-level {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-debug .log-level {
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-message {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCharts() {
|
||||||
|
await this.loadResponseTimeChart();
|
||||||
|
await this.loadErrorRateChart();
|
||||||
|
await this.loadRateLimitChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadResponseTimeChart() {
|
||||||
|
try {
|
||||||
|
// Generate demo data for response time
|
||||||
|
const labels = Array.from({ length: 20 }, (_, i) => `${i + 1}m`);
|
||||||
|
const data = {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Response Time (ms)',
|
||||||
|
data: labels.map(() => Math.floor(Math.random() * 200) + 300),
|
||||||
|
color: '#3b82f6',
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
window.chartManager.createLineChart('response-time-chart', data, {
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Milliseconds'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading response time chart:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadErrorRateChart() {
|
||||||
|
try {
|
||||||
|
const labels = Array.from({ length: 20 }, (_, i) => `${i + 1}m`);
|
||||||
|
const data = {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Error Rate (%)',
|
||||||
|
data: labels.map(() => Math.random() * 5),
|
||||||
|
color: '#ef4444',
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
window.chartManager.createLineChart('error-rate-chart', data, {
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Percentage'
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
callback: function(value) {
|
||||||
|
return value + '%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading error rate chart:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadRateLimitChart() {
|
||||||
|
try {
|
||||||
|
const labels = ['Web App', 'Mobile App', 'API Integration', 'Internal Tools', 'Testing'];
|
||||||
|
const data = {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Rate Limit Usage',
|
||||||
|
data: [65, 45, 78, 34, 60],
|
||||||
|
color: '#10b981'
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
window.chartManager.createBarChart('rate-limit-chart', data, {
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Percentage'
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
callback: function(value) {
|
||||||
|
return value + '%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading rate limit chart:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Pause/resume monitoring button
|
||||||
|
const pauseBtn = document.getElementById('pause-monitoring');
|
||||||
|
if (pauseBtn) {
|
||||||
|
pauseBtn.addEventListener('click', () => {
|
||||||
|
this.togglePause();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupWebSocketSubscriptions() {
|
||||||
|
if (!window.wsManager) return;
|
||||||
|
|
||||||
|
// Subscribe to request updates
|
||||||
|
window.wsManager.subscribe('requests', (request) => {
|
||||||
|
if (!this.isPaused) {
|
||||||
|
this.addToRequestStream(request);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to log updates
|
||||||
|
window.wsManager.subscribe('logs', (log) => {
|
||||||
|
if (!this.isPaused) {
|
||||||
|
this.addToLogStream(log);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to metric updates
|
||||||
|
window.wsManager.subscribe('metrics', (metric) => {
|
||||||
|
if (!this.isPaused) {
|
||||||
|
this.updateCharts(metric);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePause() {
|
||||||
|
this.isPaused = !this.isPaused;
|
||||||
|
const pauseBtn = document.getElementById('pause-monitoring');
|
||||||
|
|
||||||
|
if (pauseBtn) {
|
||||||
|
if (this.isPaused) {
|
||||||
|
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
|
||||||
|
pauseBtn.classList.remove('btn-secondary');
|
||||||
|
pauseBtn.classList.add('btn-success');
|
||||||
|
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Monitoring paused', 'warning');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||||
|
pauseBtn.classList.remove('btn-success');
|
||||||
|
pauseBtn.classList.add('btn-secondary');
|
||||||
|
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Monitoring resumed', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addToRequestStream(request) {
|
||||||
|
const streamElement = document.getElementById('request-stream');
|
||||||
|
if (!streamElement) return;
|
||||||
|
|
||||||
|
// Create entry
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = 'stream-entry';
|
||||||
|
|
||||||
|
// Format time
|
||||||
|
const time = new Date().toLocaleTimeString();
|
||||||
|
|
||||||
|
// Determine icon based on status
|
||||||
|
let icon = 'question-circle';
|
||||||
|
let color = 'var(--text-secondary)';
|
||||||
|
|
||||||
|
if (request.status === 'success') {
|
||||||
|
icon = 'check-circle';
|
||||||
|
color = 'var(--success)';
|
||||||
|
} else if (request.status === 'error') {
|
||||||
|
icon = 'exclamation-circle';
|
||||||
|
color = 'var(--danger)';
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.innerHTML = `
|
||||||
|
<div class="stream-entry-time">${time}</div>
|
||||||
|
<div class="stream-entry-icon" style="color: ${color}">
|
||||||
|
<i class="fas fa-${icon}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stream-entry-content">
|
||||||
|
<strong>${request.client_id || 'Unknown'}</strong> →
|
||||||
|
${request.provider || 'Unknown'} (${request.model || 'Unknown'})
|
||||||
|
<div class="stream-entry-details">
|
||||||
|
${request.tokens || 0} tokens • ${request.duration || 0}ms
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add to top of stream
|
||||||
|
streamElement.insertBefore(entry, streamElement.firstChild);
|
||||||
|
|
||||||
|
// Store in memory (limit to 100)
|
||||||
|
this.requestStream.unshift({
|
||||||
|
time,
|
||||||
|
request,
|
||||||
|
element: entry
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.requestStream.length > 100) {
|
||||||
|
const oldEntry = this.requestStream.pop();
|
||||||
|
if (oldEntry.element.parentNode) {
|
||||||
|
oldEntry.element.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add highlight animation
|
||||||
|
entry.classList.add('highlight');
|
||||||
|
setTimeout(() => entry.classList.remove('highlight'), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
addToLogStream(log) {
|
||||||
|
const logElement = document.getElementById('system-logs');
|
||||||
|
if (!logElement) return;
|
||||||
|
|
||||||
|
// Create entry
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = `log-entry log-${log.level || 'info'}`;
|
||||||
|
|
||||||
|
// Format time
|
||||||
|
const time = new Date().toLocaleTimeString();
|
||||||
|
|
||||||
|
// Determine icon based on level
|
||||||
|
let icon = 'info-circle';
|
||||||
|
if (log.level === 'error') icon = 'exclamation-circle';
|
||||||
|
if (log.level === 'warn') icon = 'exclamation-triangle';
|
||||||
|
if (log.level === 'debug') icon = 'bug';
|
||||||
|
|
||||||
|
entry.innerHTML = `
|
||||||
|
<div class="log-time">${time}</div>
|
||||||
|
<div class="log-level">
|
||||||
|
<i class="fas fa-${icon}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="log-message">${log.message || ''}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add to top of stream
|
||||||
|
logElement.insertBefore(entry, logElement.firstChild);
|
||||||
|
|
||||||
|
// Store in memory (limit to 100)
|
||||||
|
this.systemLogs.unshift({
|
||||||
|
time,
|
||||||
|
log,
|
||||||
|
element: entry
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.systemLogs.length > 100) {
|
||||||
|
const oldEntry = this.systemLogs.pop();
|
||||||
|
if (oldEntry.element.parentNode) {
|
||||||
|
oldEntry.element.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCharts(metric) {
|
||||||
|
// Update charts with new metric data
|
||||||
|
if (metric.type === 'response_time' && window.chartManager.charts.has('response-time-chart')) {
|
||||||
|
this.updateResponseTimeChart(metric.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metric.type === 'error_rate' && window.chartManager.charts.has('error-rate-chart')) {
|
||||||
|
this.updateErrorRateChart(metric.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateResponseTimeChart(value) {
|
||||||
|
window.chartManager.addDataPoint('response-time-chart', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateErrorRateChart(value) {
|
||||||
|
window.chartManager.addDataPoint('error-rate-chart', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
startDemoUpdates() {
|
||||||
|
// Simulate incoming requests for demo purposes
|
||||||
|
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||||
|
setInterval(() => {
|
||||||
|
if (!this.isPaused && Math.random() > 0.3) { // 70% chance
|
||||||
|
this.simulateRequest();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
// Simulate logs
|
||||||
|
setInterval(() => {
|
||||||
|
if (!this.isPaused && Math.random() > 0.5) { // 50% chance
|
||||||
|
this.simulateLog();
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
// Simulate metrics
|
||||||
|
setInterval(() => {
|
||||||
|
if (!this.isPaused) {
|
||||||
|
this.simulateMetric();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateRequest() {
|
||||||
|
const clients = ['client-1', 'client-2', 'client-3', 'client-4', 'client-5'];
|
||||||
|
const providers = ['OpenAI', 'Gemini', 'DeepSeek', 'Grok'];
|
||||||
|
const models = ['gpt-4', 'gpt-3.5-turbo', 'gemini-pro', 'deepseek-chat', 'grok-beta'];
|
||||||
|
const statuses = ['success', 'success', 'success', 'error', 'warning']; // Mostly success
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
client_id: clients[Math.floor(Math.random() * clients.length)],
|
||||||
|
provider: providers[Math.floor(Math.random() * providers.length)],
|
||||||
|
model: models[Math.floor(Math.random() * models.length)],
|
||||||
|
tokens: Math.floor(Math.random() * 2000) + 100,
|
||||||
|
duration: Math.floor(Math.random() * 1000) + 100,
|
||||||
|
status: statuses[Math.floor(Math.random() * statuses.length)],
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.addToRequestStream(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateLog() {
|
||||||
|
const levels = ['info', 'info', 'info', 'warn', 'error'];
|
||||||
|
const messages = [
|
||||||
|
'Request processed successfully',
|
||||||
|
'Cache hit for model gpt-4',
|
||||||
|
'Rate limit check passed',
|
||||||
|
'High latency detected for DeepSeek provider',
|
||||||
|
'API key validation failed',
|
||||||
|
'Database connection pool healthy',
|
||||||
|
'New client registered: client-7',
|
||||||
|
'Backup completed successfully',
|
||||||
|
'Memory usage above 80% threshold',
|
||||||
|
'Provider Grok is offline'
|
||||||
|
];
|
||||||
|
|
||||||
|
const log = {
|
||||||
|
level: levels[Math.floor(Math.random() * levels.length)],
|
||||||
|
message: messages[Math.floor(Math.random() * messages.length)],
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.addToLogStream(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateMetric() {
|
||||||
|
const metricTypes = ['response_time', 'error_rate'];
|
||||||
|
const type = metricTypes[Math.floor(Math.random() * metricTypes.length)];
|
||||||
|
|
||||||
|
let value;
|
||||||
|
if (type === 'response_time') {
|
||||||
|
value = Math.floor(Math.random() * 200) + 300; // 300-500ms
|
||||||
|
} else {
|
||||||
|
value = Math.random() * 5; // 0-5%
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateCharts({ type, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
clearStreams() {
|
||||||
|
const streamElement = document.getElementById('request-stream');
|
||||||
|
const logElement = document.getElementById('system-logs');
|
||||||
|
|
||||||
|
if (streamElement) {
|
||||||
|
streamElement.innerHTML = '';
|
||||||
|
this.requestStream = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logElement) {
|
||||||
|
logElement.innerHTML = '';
|
||||||
|
this.systemLogs = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this.loadSystemMetrics();
|
||||||
|
this.loadCharts();
|
||||||
|
this.clearStreams();
|
||||||
|
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Monitoring refreshed', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize monitoring page when needed
|
||||||
|
window.initMonitoring = async () => {
|
||||||
|
window.monitoringPage = new MonitoringPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = MonitoringPage;
|
||||||
|
}
|
||||||
513
static/js/pages/overview.js
Normal file
513
static/js/pages/overview.js
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
// Overview Page Module
|
||||||
|
|
||||||
|
class OverviewPage {
|
||||||
|
constructor() {
|
||||||
|
this.stats = null;
|
||||||
|
this.charts = {};
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// Load data
|
||||||
|
await this.loadStats();
|
||||||
|
await this.loadCharts();
|
||||||
|
await this.loadRecentRequests();
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
this.setupEventListeners();
|
||||||
|
|
||||||
|
// Subscribe to WebSocket updates
|
||||||
|
this.setupWebSocketSubscriptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadStats() {
|
||||||
|
try {
|
||||||
|
// In a real app, this would fetch from /api/usage/summary
|
||||||
|
// For now, use mock data
|
||||||
|
this.stats = {
|
||||||
|
totalRequests: 12458,
|
||||||
|
totalTokens: 1254300,
|
||||||
|
totalCost: 125.43,
|
||||||
|
activeClients: 8,
|
||||||
|
errorRate: 2.3,
|
||||||
|
avgResponseTime: 450,
|
||||||
|
todayRequests: 342,
|
||||||
|
todayCost: 12.45
|
||||||
|
};
|
||||||
|
|
||||||
|
this.renderStats();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading stats:', error);
|
||||||
|
this.showError('Failed to load statistics');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStats() {
|
||||||
|
const container = document.getElementById('overview-stats');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon primary">
|
||||||
|
<i class="fas fa-exchange-alt"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">${this.stats.totalRequests.toLocaleString()}</div>
|
||||||
|
<div class="stat-label">Total Requests</div>
|
||||||
|
<div class="stat-change positive">
|
||||||
|
<i class="fas fa-arrow-up"></i>
|
||||||
|
${this.stats.todayRequests} today
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon success">
|
||||||
|
<i class="fas fa-coins"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">${this.stats.totalTokens.toLocaleString()}</div>
|
||||||
|
<div class="stat-label">Total Tokens</div>
|
||||||
|
<div class="stat-change positive">
|
||||||
|
<i class="fas fa-arrow-up"></i>
|
||||||
|
12% from yesterday
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon warning">
|
||||||
|
<i class="fas fa-dollar-sign"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">$${this.stats.totalCost.toFixed(2)}</div>
|
||||||
|
<div class="stat-label">Total Cost</div>
|
||||||
|
<div class="stat-change positive">
|
||||||
|
<i class="fas fa-arrow-up"></i>
|
||||||
|
$${this.stats.todayCost.toFixed(2)} today
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon danger">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">${this.stats.activeClients}</div>
|
||||||
|
<div class="stat-label">Active Clients</div>
|
||||||
|
<div class="stat-change positive">
|
||||||
|
<i class="fas fa-arrow-up"></i>
|
||||||
|
2 new this week
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon primary">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">${this.stats.errorRate}%</div>
|
||||||
|
<div class="stat-label">Error Rate</div>
|
||||||
|
<div class="stat-change negative">
|
||||||
|
<i class="fas fa-arrow-down"></i>
|
||||||
|
0.5% improvement
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon success">
|
||||||
|
<i class="fas fa-tachometer-alt"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">${this.stats.avgResponseTime}ms</div>
|
||||||
|
<div class="stat-label">Avg Response Time</div>
|
||||||
|
<div class="stat-change positive">
|
||||||
|
<i class="fas fa-arrow-down"></i>
|
||||||
|
50ms faster
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCharts() {
|
||||||
|
await this.loadRequestsChart();
|
||||||
|
await this.loadProvidersChart();
|
||||||
|
await this.loadSystemHealth();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadRequestsChart() {
|
||||||
|
try {
|
||||||
|
// Generate demo data for requests chart
|
||||||
|
const data = window.chartManager.generateDemoTimeSeries(24, 1);
|
||||||
|
data.datasets[0].label = 'Requests per hour';
|
||||||
|
data.datasets[0].fill = true;
|
||||||
|
|
||||||
|
// Create chart
|
||||||
|
this.charts.requests = window.chartManager.createLineChart('requests-chart', data, {
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
return `Requests: ${context.parsed.y}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading requests chart:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadProvidersChart() {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
labels: ['OpenAI', 'Gemini', 'DeepSeek', 'Grok'],
|
||||||
|
data: [45, 25, 20, 10],
|
||||||
|
colors: ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6']
|
||||||
|
};
|
||||||
|
|
||||||
|
this.charts.providers = window.chartManager.createDoughnutChart('providers-chart', data, {
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
const label = context.label || '';
|
||||||
|
const value = context.raw || 0;
|
||||||
|
return `${label}: ${value}% of requests`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading providers chart:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSystemHealth() {
|
||||||
|
const container = document.getElementById('system-health');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const healthData = [
|
||||||
|
{ label: 'API Server', status: 'online', value: 100 },
|
||||||
|
{ label: 'Database', status: 'online', value: 95 },
|
||||||
|
{ label: 'OpenAI', status: 'online', value: 100 },
|
||||||
|
{ label: 'Gemini', status: 'online', value: 100 },
|
||||||
|
{ label: 'DeepSeek', status: 'warning', value: 85 },
|
||||||
|
{ label: 'Grok', status: 'offline', value: 0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
container.innerHTML = healthData.map(item => `
|
||||||
|
<div class="health-item">
|
||||||
|
<div class="health-label">
|
||||||
|
<span class="health-status status-badge ${item.status}">
|
||||||
|
<i class="fas fa-circle"></i>
|
||||||
|
${item.status}
|
||||||
|
</span>
|
||||||
|
<span class="health-name">${item.label}</span>
|
||||||
|
</div>
|
||||||
|
<div class="health-progress">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill ${item.status}" style="width: ${item.value}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="health-value">${item.value}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Add CSS for progress bars
|
||||||
|
this.addHealthStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
addHealthStyles() {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.health-item {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill.online {
|
||||||
|
background-color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill.warning {
|
||||||
|
background-color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill.offline {
|
||||||
|
background-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-value {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadRecentRequests() {
|
||||||
|
try {
|
||||||
|
// In a real app, this would fetch from /api/requests/recent
|
||||||
|
// For now, use mock data
|
||||||
|
const requests = [
|
||||||
|
{ time: '14:32:15', client: 'client-1', provider: 'OpenAI', model: 'gpt-4', tokens: 1250, status: 'success' },
|
||||||
|
{ time: '14:30:45', client: 'client-2', provider: 'Gemini', model: 'gemini-pro', tokens: 890, status: 'success' },
|
||||||
|
{ time: '14:28:12', client: 'client-3', provider: 'DeepSeek', model: 'deepseek-chat', tokens: 1560, status: 'error' },
|
||||||
|
{ time: '14:25:33', client: 'client-1', provider: 'OpenAI', model: 'gpt-3.5-turbo', tokens: 540, status: 'success' },
|
||||||
|
{ time: '14:22:18', client: 'client-4', provider: 'Grok', model: 'grok-beta', tokens: 720, status: 'success' },
|
||||||
|
{ time: '14:20:05', client: 'client-2', provider: 'Gemini', model: 'gemini-pro-vision', tokens: 1120, status: 'success' },
|
||||||
|
{ time: '14:18:47', client: 'client-5', provider: 'OpenAI', model: 'gpt-4', tokens: 980, status: 'warning' },
|
||||||
|
{ time: '14:15:22', client: 'client-3', provider: 'DeepSeek', model: 'deepseek-coder', tokens: 1340, status: 'success' },
|
||||||
|
{ time: '14:12:10', client: 'client-1', provider: 'OpenAI', model: 'gpt-3.5-turbo', tokens: 610, status: 'success' },
|
||||||
|
{ time: '14:10:05', client: 'client-6', provider: 'Gemini', model: 'gemini-pro', tokens: 830, status: 'success' }
|
||||||
|
];
|
||||||
|
|
||||||
|
this.renderRecentRequests(requests);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading recent requests:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRecentRequests(requests) {
|
||||||
|
const tableBody = document.querySelector('#recent-requests tbody');
|
||||||
|
if (!tableBody) return;
|
||||||
|
|
||||||
|
tableBody.innerHTML = requests.map(request => {
|
||||||
|
const statusClass = request.status === 'success' ? 'success' :
|
||||||
|
request.status === 'error' ? 'danger' : 'warning';
|
||||||
|
const statusIcon = request.status === 'success' ? 'check-circle' :
|
||||||
|
request.status === 'error' ? 'exclamation-circle' : 'exclamation-triangle';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${request.time}</td>
|
||||||
|
<td>${request.client}</td>
|
||||||
|
<td>${request.provider}</td>
|
||||||
|
<td>${request.model}</td>
|
||||||
|
<td>${request.tokens.toLocaleString()}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge ${statusClass}">
|
||||||
|
<i class="fas fa-${statusIcon}"></i>
|
||||||
|
${request.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Period buttons for requests chart
|
||||||
|
const periodButtons = document.querySelectorAll('.chart-control-btn[data-period]');
|
||||||
|
periodButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
// Update active state
|
||||||
|
periodButtons.forEach(btn => btn.classList.remove('active'));
|
||||||
|
button.classList.add('active');
|
||||||
|
|
||||||
|
// Update chart based on period
|
||||||
|
this.updateRequestsChart(button.dataset.period);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh button for recent requests
|
||||||
|
const refreshBtn = document.querySelector('#recent-requests .card-action-btn');
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.addEventListener('click', () => {
|
||||||
|
this.loadRecentRequests();
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Recent requests refreshed', 'success');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupWebSocketSubscriptions() {
|
||||||
|
if (!window.wsManager) return;
|
||||||
|
|
||||||
|
// Subscribe to request updates
|
||||||
|
window.wsManager.subscribe('requests', (request) => {
|
||||||
|
this.handleNewRequest(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to metric updates
|
||||||
|
window.wsManager.subscribe('metrics', (metric) => {
|
||||||
|
this.handleNewMetric(metric);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNewRequest(request) {
|
||||||
|
// Update total requests counter
|
||||||
|
if (this.stats) {
|
||||||
|
this.stats.totalRequests++;
|
||||||
|
this.stats.todayRequests++;
|
||||||
|
|
||||||
|
// Update tokens if available
|
||||||
|
if (request.tokens) {
|
||||||
|
this.stats.totalTokens += request.tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render stats
|
||||||
|
this.renderStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to recent requests table
|
||||||
|
this.addToRecentRequests(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
addToRecentRequests(request) {
|
||||||
|
const tableBody = document.querySelector('#recent-requests tbody');
|
||||||
|
if (!tableBody) return;
|
||||||
|
|
||||||
|
const time = new Date(request.timestamp || Date.now()).toLocaleTimeString();
|
||||||
|
const statusClass = request.status === 'success' ? 'success' :
|
||||||
|
request.status === 'error' ? 'danger' : 'warning';
|
||||||
|
const statusIcon = request.status === 'success' ? 'check-circle' :
|
||||||
|
request.status === 'error' ? 'exclamation-circle' : 'exclamation-triangle';
|
||||||
|
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${time}</td>
|
||||||
|
<td>${request.client_id || 'Unknown'}</td>
|
||||||
|
<td>${request.provider || 'Unknown'}</td>
|
||||||
|
<td>${request.model || 'Unknown'}</td>
|
||||||
|
<td>${request.tokens || 0}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge ${statusClass}">
|
||||||
|
<i class="fas fa-${statusIcon}"></i>
|
||||||
|
${request.status || 'unknown'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add to top of table
|
||||||
|
tableBody.insertBefore(row, tableBody.firstChild);
|
||||||
|
|
||||||
|
// Limit to 50 rows
|
||||||
|
const rows = tableBody.querySelectorAll('tr');
|
||||||
|
if (rows.length > 50) {
|
||||||
|
tableBody.removeChild(rows[rows.length - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNewMetric(metric) {
|
||||||
|
// Update charts with new metric data
|
||||||
|
if (metric.type === 'requests' && this.charts.requests) {
|
||||||
|
this.updateRequestsChartData(metric);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update system health if needed
|
||||||
|
if (metric.type === 'system_health') {
|
||||||
|
this.updateSystemHealth(metric);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRequestsChart(period) {
|
||||||
|
// In a real app, this would fetch new data based on period
|
||||||
|
// For now, just update with demo data
|
||||||
|
let hours = 24;
|
||||||
|
if (period === '7d') hours = 24 * 7;
|
||||||
|
if (period === '30d') hours = 24 * 30;
|
||||||
|
|
||||||
|
const data = window.chartManager.generateDemoTimeSeries(hours, 1);
|
||||||
|
data.datasets[0].label = 'Requests';
|
||||||
|
data.datasets[0].fill = true;
|
||||||
|
|
||||||
|
window.chartManager.updateChartData('requests-chart', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRequestsChartData(metric) {
|
||||||
|
// Add new data point to the chart
|
||||||
|
if (this.charts.requests && metric.value !== undefined) {
|
||||||
|
window.chartManager.addDataPoint('requests-chart', metric.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSystemHealth(metric) {
|
||||||
|
// Update system health indicators
|
||||||
|
const container = document.getElementById('system-health');
|
||||||
|
if (!container || !metric.data) return;
|
||||||
|
|
||||||
|
// This would update specific health indicators based on metric data
|
||||||
|
// Implementation depends on metric structure
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
const container = document.getElementById('overview-stats');
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="error-message" style="grid-column: 1 / -1;">
|
||||||
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
|
<span>${message}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this.loadStats();
|
||||||
|
this.loadRecentRequests();
|
||||||
|
|
||||||
|
// Refresh charts
|
||||||
|
if (this.charts.requests) {
|
||||||
|
this.charts.requests.update();
|
||||||
|
}
|
||||||
|
if (this.charts.providers) {
|
||||||
|
this.charts.providers.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize overview page when needed
|
||||||
|
window.initOverview = async () => {
|
||||||
|
window.overviewPage = new OverviewPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = OverviewPage;
|
||||||
|
}
|
||||||
650
static/js/pages/providers.js
Normal file
650
static/js/pages/providers.js
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
// Providers Page Module
|
||||||
|
|
||||||
|
class ProvidersPage {
|
||||||
|
constructor() {
|
||||||
|
this.providers = [];
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// Load data
|
||||||
|
await this.loadProviderStats();
|
||||||
|
await this.loadProvidersList();
|
||||||
|
await this.loadModelsList();
|
||||||
|
await this.loadConnectionTests();
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadProviderStats() {
|
||||||
|
const container = document.getElementById('provider-stats');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon primary">
|
||||||
|
<i class="fas fa-server"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">4</div>
|
||||||
|
<div class="stat-label">Total Providers</div>
|
||||||
|
<div class="stat-change">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
3 active
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon success">
|
||||||
|
<i class="fas fa-plug"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">3</div>
|
||||||
|
<div class="stat-label">Connected</div>
|
||||||
|
<div class="stat-change positive">
|
||||||
|
<i class="fas fa-arrow-up"></i>
|
||||||
|
All systems operational
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon warning">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">1</div>
|
||||||
|
<div class="stat-label">Issues</div>
|
||||||
|
<div class="stat-change">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
DeepSeek: 85% health
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon danger">
|
||||||
|
<i class="fas fa-times-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">1</div>
|
||||||
|
<div class="stat-label">Offline</div>
|
||||||
|
<div class="stat-change">
|
||||||
|
<i class="fas fa-redo"></i>
|
||||||
|
Grok: Connection failed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadProvidersList() {
|
||||||
|
const container = document.getElementById('providers-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
this.providers = [
|
||||||
|
{ name: 'OpenAI', enabled: true, status: 'online', apiKey: 'sk-*****123', models: ['gpt-4', 'gpt-3.5-turbo'], lastUsed: '2024-01-15 14:32:15' },
|
||||||
|
{ name: 'Gemini', enabled: true, status: 'online', apiKey: 'AIza*****456', models: ['gemini-pro', 'gemini-pro-vision'], lastUsed: '2024-01-15 14:30:45' },
|
||||||
|
{ name: 'DeepSeek', enabled: true, status: 'warning', apiKey: 'sk-*****789', models: ['deepseek-chat', 'deepseek-coder'], lastUsed: '2024-01-15 14:28:12' },
|
||||||
|
{ name: 'Grok', enabled: false, status: 'offline', apiKey: 'gk-*****012', models: ['grok-beta'], lastUsed: '2024-01-12 10:15:22' }
|
||||||
|
];
|
||||||
|
|
||||||
|
container.innerHTML = this.providers.map(provider => {
|
||||||
|
const statusClass = provider.status === 'online' ? 'success' :
|
||||||
|
provider.status === 'warning' ? 'warning' : 'danger';
|
||||||
|
const statusIcon = provider.status === 'online' ? 'check-circle' :
|
||||||
|
provider.status === 'warning' ? 'exclamation-triangle' : 'times-circle';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="provider-card">
|
||||||
|
<div class="provider-header">
|
||||||
|
<div class="provider-info">
|
||||||
|
<h4 class="provider-name">${provider.name}</h4>
|
||||||
|
<span class="status-badge ${statusClass}">
|
||||||
|
<i class="fas fa-${statusIcon}"></i>
|
||||||
|
${provider.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="provider-actions">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" ${provider.enabled ? 'checked' : ''} data-provider="${provider.name}">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
<button class="btn-action" title="Configure" data-action="configure" data-provider="${provider.name}">
|
||||||
|
<i class="fas fa-cog"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn-action" title="Test Connection" data-action="test" data-provider="${provider.name}">
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="provider-details">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">API Key:</span>
|
||||||
|
<code class="detail-value">${provider.apiKey}</code>
|
||||||
|
<button class="btn-copy" data-text="${provider.apiKey}" title="Copy">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Models:</span>
|
||||||
|
<span class="detail-value">${provider.models.join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Last Used:</span>
|
||||||
|
<span class="detail-value">${provider.lastUsed}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Add CSS for provider cards
|
||||||
|
this.addProviderStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
addProviderStyles() {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.provider-card {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 50px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--text-light);
|
||||||
|
transition: .4s;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
left: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
background-color: white;
|
||||||
|
transition: .4s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .toggle-slider {
|
||||||
|
background-color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .toggle-slider:before {
|
||||||
|
transform: translateX(26px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadModelsList() {
|
||||||
|
const container = document.getElementById('models-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const models = [
|
||||||
|
{ provider: 'OpenAI', name: 'gpt-4', enabled: true, context: 8192, maxTokens: 4096 },
|
||||||
|
{ provider: 'OpenAI', name: 'gpt-3.5-turbo', enabled: true, context: 16384, maxTokens: 4096 },
|
||||||
|
{ provider: 'Gemini', name: 'gemini-pro', enabled: true, context: 32768, maxTokens: 8192 },
|
||||||
|
{ provider: 'Gemini', name: 'gemini-pro-vision', enabled: true, context: 32768, maxTokens: 4096 },
|
||||||
|
{ provider: 'DeepSeek', name: 'deepseek-chat', enabled: true, context: 16384, maxTokens: 4096 },
|
||||||
|
{ provider: 'DeepSeek', name: 'deepseek-coder', enabled: true, context: 16384, maxTokens: 4096 },
|
||||||
|
{ provider: 'Grok', name: 'grok-beta', enabled: false, context: 8192, maxTokens: 2048 }
|
||||||
|
];
|
||||||
|
|
||||||
|
container.innerHTML = models.map(model => `
|
||||||
|
<div class="model-item">
|
||||||
|
<div class="model-header">
|
||||||
|
<span class="model-name">${model.name}</span>
|
||||||
|
<span class="model-provider">${model.provider}</span>
|
||||||
|
</div>
|
||||||
|
<div class="model-details">
|
||||||
|
<span class="model-detail">
|
||||||
|
<i class="fas fa-microchip"></i>
|
||||||
|
Context: ${model.context.toLocaleString()} tokens
|
||||||
|
</span>
|
||||||
|
<span class="model-detail">
|
||||||
|
<i class="fas fa-ruler"></i>
|
||||||
|
Max: ${model.maxTokens.toLocaleString()} tokens
|
||||||
|
</span>
|
||||||
|
<span class="model-status ${model.enabled ? 'enabled' : 'disabled'}">
|
||||||
|
<i class="fas fa-${model.enabled ? 'check' : 'times'}"></i>
|
||||||
|
${model.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Add CSS for model items
|
||||||
|
this.addModelStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
addModelStyles() {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.model-item {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-provider {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-details {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-detail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-detail i {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-status {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-status.enabled {
|
||||||
|
background-color: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-status.disabled {
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadConnectionTests() {
|
||||||
|
const container = document.getElementById('connection-tests');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const tests = [
|
||||||
|
{ provider: 'OpenAI', status: 'success', latency: 245, timestamp: '2024-01-15 14:35:00' },
|
||||||
|
{ provider: 'Gemini', status: 'success', latency: 189, timestamp: '2024-01-15 14:34:30' },
|
||||||
|
{ provider: 'DeepSeek', status: 'warning', latency: 520, timestamp: '2024-01-15 14:34:00' },
|
||||||
|
{ provider: 'Grok', status: 'error', latency: null, timestamp: '2024-01-15 14:33:30' }
|
||||||
|
];
|
||||||
|
|
||||||
|
container.innerHTML = tests.map(test => {
|
||||||
|
const statusClass = test.status === 'success' ? 'success' :
|
||||||
|
test.status === 'warning' ? 'warning' : 'danger';
|
||||||
|
const statusIcon = test.status === 'success' ? 'check-circle' :
|
||||||
|
test.status === 'warning' ? 'exclamation-triangle' : 'times-circle';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="test-result">
|
||||||
|
<div class="test-provider">${test.provider}</div>
|
||||||
|
<div class="test-status">
|
||||||
|
<span class="status-badge ${statusClass}">
|
||||||
|
<i class="fas fa-${statusIcon}"></i>
|
||||||
|
${test.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="test-latency">${test.latency ? `${test.latency}ms` : 'N/A'}</div>
|
||||||
|
<div class="test-time">${test.timestamp}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Add CSS for test results
|
||||||
|
this.addTestStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
addTestStyles() {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.test-result {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr 2fr;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-provider {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-latency {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-time {
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Test all providers button
|
||||||
|
const testAllBtn = document.getElementById('test-all-providers');
|
||||||
|
if (testAllBtn) {
|
||||||
|
testAllBtn.addEventListener('click', () => {
|
||||||
|
this.testAllProviders();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle switches
|
||||||
|
document.addEventListener('change', (e) => {
|
||||||
|
if (e.target.matches('.toggle-switch input')) {
|
||||||
|
const provider = e.target.dataset.provider;
|
||||||
|
const enabled = e.target.checked;
|
||||||
|
this.toggleProvider(provider, enabled);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.btn-action')) {
|
||||||
|
const button = e.target.closest('.btn-action');
|
||||||
|
const action = button.dataset.action;
|
||||||
|
const provider = button.dataset.provider;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'configure':
|
||||||
|
this.configureProvider(provider);
|
||||||
|
break;
|
||||||
|
case 'test':
|
||||||
|
this.testProvider(provider);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy buttons
|
||||||
|
if (e.target.closest('.btn-copy')) {
|
||||||
|
const button = e.target.closest('.btn-copy');
|
||||||
|
const text = button.dataset.text;
|
||||||
|
this.copyToClipboard(text);
|
||||||
|
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Copied to clipboard', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleProvider(providerName, enabled) {
|
||||||
|
const provider = this.providers.find(p => p.name === providerName);
|
||||||
|
if (!provider) return;
|
||||||
|
|
||||||
|
// In a real app, this would update the provider via API
|
||||||
|
provider.enabled = enabled;
|
||||||
|
provider.status = enabled ? 'online' : 'offline';
|
||||||
|
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast(
|
||||||
|
`${providerName} ${enabled ? 'enabled' : 'disabled'}`,
|
||||||
|
enabled ? 'success' : 'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh providers list
|
||||||
|
this.loadProvidersList();
|
||||||
|
}
|
||||||
|
|
||||||
|
configureProvider(providerName) {
|
||||||
|
const provider = this.providers.find(p => p.name === providerName);
|
||||||
|
if (!provider) return;
|
||||||
|
|
||||||
|
// Show configuration modal
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'modal active';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Configure ${providerName}</h3>
|
||||||
|
<button class="modal-close">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="configure-provider-form">
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="api-key">API Key</label>
|
||||||
|
<input type="password" id="api-key" value="${provider.apiKey}" placeholder="Enter API key" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="base-url">Base URL (Optional)</label>
|
||||||
|
<input type="text" id="base-url" placeholder="https://api.openai.com/v1">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="timeout">Timeout (seconds)</label>
|
||||||
|
<input type="number" id="timeout" value="30" min="1" max="300">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="retry-count">Retry Count</label>
|
||||||
|
<input type="number" id="retry-count" value="3" min="0" max="10">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary close-modal">Cancel</button>
|
||||||
|
<button class="btn btn-primary save-config">Save Configuration</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
const closeBtn = modal.querySelector('.modal-close');
|
||||||
|
const closeModalBtn = modal.querySelector('.close-modal');
|
||||||
|
const saveBtn = modal.querySelector('.save-config');
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
setTimeout(() => modal.remove(), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
closeBtn.addEventListener('click', closeModal);
|
||||||
|
closeModalBtn.addEventListener('click', closeModal);
|
||||||
|
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
// In a real app, this would save provider configuration
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast(`${providerName} configuration saved`, 'success');
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on background click
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testProvider(providerName) {
|
||||||
|
const provider = this.providers.find(p => p.name === providerName);
|
||||||
|
if (!provider) return;
|
||||||
|
|
||||||
|
// Show testing in progress
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast(`Testing ${providerName} connection...`, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate API test
|
||||||
|
setTimeout(() => {
|
||||||
|
// In a real app, this would test the provider connection via API
|
||||||
|
const success = Math.random() > 0.3; // 70% success rate for demo
|
||||||
|
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast(
|
||||||
|
`${providerName} connection ${success ? 'successful' : 'failed'}`,
|
||||||
|
success ? 'success' : 'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh connection tests
|
||||||
|
this.loadConnectionTests();
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
testAllProviders() {
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Testing all providers...', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test each provider sequentially
|
||||||
|
this.providers.forEach((provider, index) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.testProvider(provider.name);
|
||||||
|
}, index * 2000); // Stagger tests
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
copyToClipboard(text) {
|
||||||
|
navigator.clipboard.writeText(text).catch(err => {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this.loadProviderStats();
|
||||||
|
this.loadProvidersList();
|
||||||
|
this.loadModelsList();
|
||||||
|
this.loadConnectionTests();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize providers page when needed
|
||||||
|
window.initProviders = async () => {
|
||||||
|
window.providersPage = new ProvidersPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = ProvidersPage;
|
||||||
|
}
|
||||||
318
static/js/pages/settings.js
Normal file
318
static/js/pages/settings.js
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
// Settings Page Module
|
||||||
|
|
||||||
|
class SettingsPage {
|
||||||
|
constructor() {
|
||||||
|
this.settings = {};
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// Load settings
|
||||||
|
await this.loadSettings();
|
||||||
|
await this.loadSystemInfo();
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSettings() {
|
||||||
|
try {
|
||||||
|
// In a real app, this would fetch from /api/settings
|
||||||
|
this.settings = {
|
||||||
|
serverPort: 8080,
|
||||||
|
logLevel: 'info',
|
||||||
|
dbPath: './data/llm-proxy.db',
|
||||||
|
backupInterval: 24,
|
||||||
|
sessionTimeout: 30,
|
||||||
|
enableRateLimiting: true,
|
||||||
|
enableCostTracking: true,
|
||||||
|
enableMetrics: true,
|
||||||
|
enableWebSocket: true
|
||||||
|
};
|
||||||
|
|
||||||
|
this.renderSettingsForm();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading settings:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSettingsForm() {
|
||||||
|
const form = document.getElementById('settings-form');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
// Server port
|
||||||
|
const portInput = document.getElementById('server-port');
|
||||||
|
if (portInput) portInput.value = this.settings.serverPort;
|
||||||
|
|
||||||
|
// Log level
|
||||||
|
const logLevelSelect = document.getElementById('log-level');
|
||||||
|
if (logLevelSelect) logLevelSelect.value = this.settings.logLevel;
|
||||||
|
|
||||||
|
// Database path
|
||||||
|
const dbPathInput = document.getElementById('db-path');
|
||||||
|
if (dbPathInput) dbPathInput.value = this.settings.dbPath;
|
||||||
|
|
||||||
|
// Backup interval
|
||||||
|
const backupInput = document.getElementById('backup-interval');
|
||||||
|
if (backupInput) backupInput.value = this.settings.backupInterval;
|
||||||
|
|
||||||
|
// Session timeout
|
||||||
|
const sessionInput = document.getElementById('session-timeout');
|
||||||
|
if (sessionInput) sessionInput.value = this.settings.sessionTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSystemInfo() {
|
||||||
|
const container = document.getElementById('system-info');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// In a real app, this would fetch system information
|
||||||
|
const systemInfo = {
|
||||||
|
version: '1.0.0',
|
||||||
|
uptime: '5 days, 3 hours',
|
||||||
|
platform: 'Linux x86_64',
|
||||||
|
node: 'v18.17.0',
|
||||||
|
memory: '2.4 GB / 8.0 GB',
|
||||||
|
disk: '45 GB / 256 GB',
|
||||||
|
lastBackup: '2024-01-15 02:00:00',
|
||||||
|
lastRestart: '2024-01-10 14:30:00'
|
||||||
|
};
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Version:</span>
|
||||||
|
<span class="info-value">${systemInfo.version}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Uptime:</span>
|
||||||
|
<span class="info-value">${systemInfo.uptime}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Platform:</span>
|
||||||
|
<span class="info-value">${systemInfo.platform}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Node.js:</span>
|
||||||
|
<span class="info-value">${systemInfo.node}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Memory:</span>
|
||||||
|
<span class="info-value">${systemInfo.memory}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Disk:</span>
|
||||||
|
<span class="info-value">${systemInfo.disk}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Last Backup:</span>
|
||||||
|
<span class="info-value">${systemInfo.lastBackup}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Last Restart:</span>
|
||||||
|
<span class="info-value">${systemInfo.lastRestart}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add CSS for info grid
|
||||||
|
this.addInfoStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
addInfoStyles() {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section h4 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Settings form
|
||||||
|
const form = document.getElementById('settings-form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.saveSettings();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset settings button
|
||||||
|
const resetBtn = document.getElementById('reset-settings');
|
||||||
|
if (resetBtn) {
|
||||||
|
resetBtn.addEventListener('click', () => {
|
||||||
|
this.resetSettings();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database management buttons
|
||||||
|
const backupBtn = document.getElementById('backup-db');
|
||||||
|
if (backupBtn) {
|
||||||
|
backupBtn.addEventListener('click', () => {
|
||||||
|
this.backupDatabase();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const optimizeBtn = document.getElementById('optimize-db');
|
||||||
|
if (optimizeBtn) {
|
||||||
|
optimizeBtn.addEventListener('click', () => {
|
||||||
|
this.optimizeDatabase();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSettings() {
|
||||||
|
// Collect form values
|
||||||
|
const settings = {
|
||||||
|
serverPort: parseInt(document.getElementById('server-port').value) || 8080,
|
||||||
|
logLevel: document.getElementById('log-level').value,
|
||||||
|
dbPath: document.getElementById('db-path').value,
|
||||||
|
backupInterval: parseInt(document.getElementById('backup-interval').value) || 24,
|
||||||
|
sessionTimeout: parseInt(document.getElementById('session-timeout').value) || 30,
|
||||||
|
dashboardPassword: document.getElementById('dashboard-password').value
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate settings
|
||||||
|
if (settings.serverPort < 1024 || settings.serverPort > 65535) {
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Server port must be between 1024 and 65535', 'error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.backupInterval < 1 || settings.backupInterval > 168) {
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Backup interval must be between 1 and 168 hours', 'error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.sessionTimeout < 5 || settings.sessionTimeout > 1440) {
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Session timeout must be between 5 and 1440 minutes', 'error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a real app, this would save settings via API
|
||||||
|
this.settings = { ...this.settings, ...settings };
|
||||||
|
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Settings saved successfully', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear password field
|
||||||
|
document.getElementById('dashboard-password').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
resetSettings() {
|
||||||
|
if (confirm('Are you sure you want to reset all settings to default values?')) {
|
||||||
|
// Reset to defaults
|
||||||
|
this.settings = {
|
||||||
|
serverPort: 8080,
|
||||||
|
logLevel: 'info',
|
||||||
|
dbPath: './data/llm-proxy.db',
|
||||||
|
backupInterval: 24,
|
||||||
|
sessionTimeout: 30,
|
||||||
|
enableRateLimiting: true,
|
||||||
|
enableCostTracking: true,
|
||||||
|
enableMetrics: true,
|
||||||
|
enableWebSocket: true
|
||||||
|
};
|
||||||
|
|
||||||
|
this.renderSettingsForm();
|
||||||
|
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Settings reset to defaults', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
backupDatabase() {
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Starting database backup...', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate backup process
|
||||||
|
setTimeout(() => {
|
||||||
|
// In a real app, this would trigger a database backup via API
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Database backup completed successfully', 'success');
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
optimizeDatabase() {
|
||||||
|
if (confirm('Optimize database? This may improve performance but could take a few moments.')) {
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Optimizing database...', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate optimization process
|
||||||
|
setTimeout(() => {
|
||||||
|
// In a real app, this would optimize the database via API
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast('Database optimization completed', 'success');
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this.loadSettings();
|
||||||
|
this.loadSystemInfo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize settings page when needed
|
||||||
|
window.initSettings = async () => {
|
||||||
|
window.settingsPage = new SettingsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = SettingsPage;
|
||||||
|
}
|
||||||
510
static/js/websocket.js
Normal file
510
static/js/websocket.js
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
// WebSocket Manager for Real-time Updates
|
||||||
|
|
||||||
|
class WebSocketManager {
|
||||||
|
constructor() {
|
||||||
|
this.ws = null;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.maxReconnectAttempts = 5;
|
||||||
|
this.reconnectDelay = 1000;
|
||||||
|
this.isConnected = false;
|
||||||
|
this.subscribers = new Map();
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.connect();
|
||||||
|
this.setupStatusIndicator();
|
||||||
|
this.setupAutoReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
try {
|
||||||
|
// Determine WebSocket URL
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = window.location.host;
|
||||||
|
const wsUrl = `${protocol}//${host}/ws`;
|
||||||
|
|
||||||
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
this.ws.onopen = () => this.onOpen();
|
||||||
|
this.ws.onclose = () => this.onClose();
|
||||||
|
this.ws.onerror = (error) => this.onError(error);
|
||||||
|
this.ws.onmessage = (event) => this.onMessage(event);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WebSocket connection error:', error);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen() {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
this.isConnected = true;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.updateStatus('connected');
|
||||||
|
|
||||||
|
// Notify subscribers
|
||||||
|
this.notify('connection', { status: 'connected' });
|
||||||
|
|
||||||
|
// Send authentication if needed
|
||||||
|
if (window.authManager && window.authManager.token) {
|
||||||
|
this.send({
|
||||||
|
type: 'auth',
|
||||||
|
token: window.authManager.token
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to default channels
|
||||||
|
this.send({
|
||||||
|
type: 'subscribe',
|
||||||
|
channels: ['requests', 'metrics', 'logs']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
console.log('WebSocket disconnected');
|
||||||
|
this.isConnected = false;
|
||||||
|
this.updateStatus('disconnected');
|
||||||
|
|
||||||
|
// Notify subscribers
|
||||||
|
this.notify('connection', { status: 'disconnected' });
|
||||||
|
|
||||||
|
// Schedule reconnection
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
onError(error) {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
this.updateStatus('error');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessage(event) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
this.handleMessage(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing WebSocket message:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(data) {
|
||||||
|
const { type, channel, payload } = data;
|
||||||
|
|
||||||
|
// Notify channel subscribers
|
||||||
|
if (channel && this.subscribers.has(channel)) {
|
||||||
|
this.subscribers.get(channel).forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(payload);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in WebSocket callback:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle specific message types
|
||||||
|
switch (type) {
|
||||||
|
case 'request':
|
||||||
|
this.handleRequest(payload);
|
||||||
|
break;
|
||||||
|
case 'metric':
|
||||||
|
this.handleMetric(payload);
|
||||||
|
break;
|
||||||
|
case 'log':
|
||||||
|
this.handleLog(payload);
|
||||||
|
break;
|
||||||
|
case 'system':
|
||||||
|
this.handleSystem(payload);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
this.handleError(payload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRequest(request) {
|
||||||
|
// Update request counters
|
||||||
|
this.updateRequestCounters(request);
|
||||||
|
|
||||||
|
// Add to recent requests if on overview page
|
||||||
|
if (window.dashboard && window.dashboard.currentPage === 'overview') {
|
||||||
|
this.addRecentRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update monitoring stream if on monitoring page
|
||||||
|
if (window.dashboard && window.dashboard.currentPage === 'monitoring') {
|
||||||
|
this.addToMonitoringStream(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMetric(metric) {
|
||||||
|
// Update charts with new metric data
|
||||||
|
this.updateCharts(metric);
|
||||||
|
|
||||||
|
// Update system metrics display
|
||||||
|
this.updateSystemMetrics(metric);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLog(log) {
|
||||||
|
// Add to logs table if on logs page
|
||||||
|
if (window.dashboard && window.dashboard.currentPage === 'logs') {
|
||||||
|
this.addLogEntry(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to monitoring logs if on monitoring page
|
||||||
|
if (window.dashboard && window.dashboard.currentPage === 'monitoring') {
|
||||||
|
this.addToLogStream(log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSystem(system) {
|
||||||
|
// Update system health indicators
|
||||||
|
this.updateSystemHealth(system);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleError(error) {
|
||||||
|
console.error('Server error:', error);
|
||||||
|
|
||||||
|
// Show error toast
|
||||||
|
if (window.authManager) {
|
||||||
|
window.authManager.showToast(error.message || 'Server error', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data) {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify(data));
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.warn('WebSocket not connected, message not sent:', data);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(channel, callback) {
|
||||||
|
if (!this.subscribers.has(channel)) {
|
||||||
|
this.subscribers.set(channel, new Set());
|
||||||
|
}
|
||||||
|
this.subscribers.get(channel).add(callback);
|
||||||
|
|
||||||
|
// Send subscription to server
|
||||||
|
this.send({
|
||||||
|
type: 'subscribe',
|
||||||
|
channels: [channel]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => this.unsubscribe(channel, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe(channel, callback) {
|
||||||
|
if (this.subscribers.has(channel)) {
|
||||||
|
this.subscribers.get(channel).delete(callback);
|
||||||
|
|
||||||
|
// If no more subscribers, unsubscribe from server
|
||||||
|
if (this.subscribers.get(channel).size === 0) {
|
||||||
|
this.send({
|
||||||
|
type: 'unsubscribe',
|
||||||
|
channels: [channel]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(channel, data) {
|
||||||
|
if (this.subscribers.has(channel)) {
|
||||||
|
this.subscribers.get(channel).forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in notification callback:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleReconnect() {
|
||||||
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||||
|
console.warn('Max reconnection attempts reached');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
||||||
|
|
||||||
|
console.log(`Scheduling reconnection in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupAutoReconnect() {
|
||||||
|
// Reconnect when browser comes online
|
||||||
|
window.addEventListener('online', () => {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
console.log('Browser online, attempting to reconnect...');
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keepalive ping
|
||||||
|
setInterval(() => {
|
||||||
|
if (this.isConnected) {
|
||||||
|
this.send({ type: 'ping' });
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupStatusIndicator() {
|
||||||
|
// Status indicator is already in the HTML
|
||||||
|
// This function just ensures it's properly styled
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(status) {
|
||||||
|
const statusElement = document.getElementById('ws-status');
|
||||||
|
if (!statusElement) return;
|
||||||
|
|
||||||
|
const dot = statusElement.querySelector('.ws-dot');
|
||||||
|
const text = statusElement.querySelector('.ws-text');
|
||||||
|
|
||||||
|
if (!dot || !text) return;
|
||||||
|
|
||||||
|
// Remove all status classes
|
||||||
|
dot.classList.remove('connected', 'disconnected');
|
||||||
|
statusElement.classList.remove('connected', 'disconnected');
|
||||||
|
|
||||||
|
// Add new status class
|
||||||
|
dot.classList.add(status);
|
||||||
|
statusElement.classList.add(status);
|
||||||
|
|
||||||
|
// Update text
|
||||||
|
const statusText = {
|
||||||
|
'connected': 'Connected',
|
||||||
|
'disconnected': 'Disconnected',
|
||||||
|
'connecting': 'Connecting...',
|
||||||
|
'error': 'Connection Error'
|
||||||
|
};
|
||||||
|
|
||||||
|
text.textContent = statusText[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for updating UI
|
||||||
|
updateRequestCounters(request) {
|
||||||
|
// Update request counters in overview stats
|
||||||
|
const requestCountElement = document.querySelector('[data-stat="total-requests"]');
|
||||||
|
if (requestCountElement) {
|
||||||
|
const currentCount = parseInt(requestCountElement.textContent) || 0;
|
||||||
|
requestCountElement.textContent = currentCount + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update token counters
|
||||||
|
const tokenCountElement = document.querySelector('[data-stat="total-tokens"]');
|
||||||
|
if (tokenCountElement && request.tokens) {
|
||||||
|
const currentTokens = parseInt(tokenCountElement.textContent) || 0;
|
||||||
|
tokenCountElement.textContent = currentTokens + request.tokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addRecentRequest(request) {
|
||||||
|
const tableBody = document.querySelector('#recent-requests tbody');
|
||||||
|
if (!tableBody) return;
|
||||||
|
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
|
||||||
|
// Format time
|
||||||
|
const time = new Date(request.timestamp || Date.now()).toLocaleTimeString();
|
||||||
|
|
||||||
|
// Format status badge
|
||||||
|
const statusClass = request.status === 'success' ? 'success' :
|
||||||
|
request.status === 'error' ? 'danger' : 'warning';
|
||||||
|
const statusIcon = request.status === 'success' ? 'check-circle' :
|
||||||
|
request.status === 'error' ? 'exclamation-circle' : 'exclamation-triangle';
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${time}</td>
|
||||||
|
<td>${request.client_id || 'Unknown'}</td>
|
||||||
|
<td>${request.provider || 'Unknown'}</td>
|
||||||
|
<td>${request.model || 'Unknown'}</td>
|
||||||
|
<td>${request.tokens || 0}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge ${statusClass}">
|
||||||
|
<i class="fas fa-${statusIcon}"></i>
|
||||||
|
${request.status || 'unknown'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add to top of table
|
||||||
|
tableBody.insertBefore(row, tableBody.firstChild);
|
||||||
|
|
||||||
|
// Limit to 50 rows
|
||||||
|
const rows = tableBody.querySelectorAll('tr');
|
||||||
|
if (rows.length > 50) {
|
||||||
|
tableBody.removeChild(rows[rows.length - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addToMonitoringStream(request) {
|
||||||
|
const streamElement = document.getElementById('request-stream');
|
||||||
|
if (!streamElement) return;
|
||||||
|
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = 'stream-entry';
|
||||||
|
|
||||||
|
// Format time
|
||||||
|
const time = new Date().toLocaleTimeString();
|
||||||
|
|
||||||
|
// Determine icon based on status
|
||||||
|
let icon = 'question-circle';
|
||||||
|
let color = 'var(--text-secondary)';
|
||||||
|
|
||||||
|
if (request.status === 'success') {
|
||||||
|
icon = 'check-circle';
|
||||||
|
color = 'var(--success)';
|
||||||
|
} else if (request.status === 'error') {
|
||||||
|
icon = 'exclamation-circle';
|
||||||
|
color = 'var(--danger)';
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.innerHTML = `
|
||||||
|
<div class="stream-entry-time">${time}</div>
|
||||||
|
<div class="stream-entry-icon" style="color: ${color}">
|
||||||
|
<i class="fas fa-${icon}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stream-entry-content">
|
||||||
|
<strong>${request.client_id || 'Unknown'}</strong> →
|
||||||
|
${request.provider || 'Unknown'} (${request.model || 'Unknown'})
|
||||||
|
<div class="stream-entry-details">
|
||||||
|
${request.tokens || 0} tokens • ${request.duration || 0}ms
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add to top of stream
|
||||||
|
streamElement.insertBefore(entry, streamElement.firstChild);
|
||||||
|
|
||||||
|
// Limit to 20 entries
|
||||||
|
const entries = streamElement.querySelectorAll('.stream-entry');
|
||||||
|
if (entries.length > 20) {
|
||||||
|
streamElement.removeChild(entries[entries.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add highlight animation
|
||||||
|
entry.classList.add('highlight');
|
||||||
|
setTimeout(() => entry.classList.remove('highlight'), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCharts(metric) {
|
||||||
|
// This would update Chart.js charts with new data
|
||||||
|
// Implementation depends on specific chart setup
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSystemMetrics(metric) {
|
||||||
|
const metricsElement = document.getElementById('system-metrics');
|
||||||
|
if (!metricsElement) return;
|
||||||
|
|
||||||
|
// Update specific metric displays
|
||||||
|
// This is a simplified example
|
||||||
|
}
|
||||||
|
|
||||||
|
addLogEntry(log) {
|
||||||
|
const tableBody = document.querySelector('#logs-table tbody');
|
||||||
|
if (!tableBody) return;
|
||||||
|
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
|
||||||
|
// Format time
|
||||||
|
const time = new Date(log.timestamp || Date.now()).toLocaleString();
|
||||||
|
|
||||||
|
// Determine log level class
|
||||||
|
const levelClass = log.level || 'info';
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${time}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge ${levelClass}">
|
||||||
|
${levelClass.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>${log.source || 'Unknown'}</td>
|
||||||
|
<td>${log.message || ''}</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add to top of table
|
||||||
|
tableBody.insertBefore(row, tableBody.firstChild);
|
||||||
|
|
||||||
|
// Limit to 100 rows
|
||||||
|
const rows = tableBody.querySelectorAll('tr');
|
||||||
|
if (rows.length > 100) {
|
||||||
|
tableBody.removeChild(rows[rows.length - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addToLogStream(log) {
|
||||||
|
const logStreamElement = document.getElementById('system-logs');
|
||||||
|
if (!logStreamElement) return;
|
||||||
|
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = `log-entry log-${log.level || 'info'}`;
|
||||||
|
|
||||||
|
// Format time
|
||||||
|
const time = new Date().toLocaleTimeString();
|
||||||
|
|
||||||
|
// Determine icon based on level
|
||||||
|
let icon = 'info-circle';
|
||||||
|
if (log.level === 'error') icon = 'exclamation-circle';
|
||||||
|
if (log.level === 'warn') icon = 'exclamation-triangle';
|
||||||
|
if (log.level === 'debug') icon = 'bug';
|
||||||
|
|
||||||
|
entry.innerHTML = `
|
||||||
|
<div class="log-time">${time}</div>
|
||||||
|
<div class="log-level">
|
||||||
|
<i class="fas fa-${icon}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="log-message">${log.message || ''}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add to top of stream
|
||||||
|
logStreamElement.insertBefore(entry, logStreamElement.firstChild);
|
||||||
|
|
||||||
|
// Limit to 50 entries
|
||||||
|
const entries = logStreamElement.querySelectorAll('.log-entry');
|
||||||
|
if (entries.length > 50) {
|
||||||
|
logStreamElement.removeChild(entries[entries.length - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSystemHealth(system) {
|
||||||
|
const healthElement = document.getElementById('system-health');
|
||||||
|
if (!healthElement) return;
|
||||||
|
|
||||||
|
// Update system health indicators
|
||||||
|
// This is a simplified example
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
this.isConnected = false;
|
||||||
|
this.updateStatus('disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnect() {
|
||||||
|
this.disconnect();
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize WebSocket manager when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.wsManager = new WebSocketManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = WebSocketManager;
|
||||||
|
}
|
||||||
38
test_dashboard.sh
Executable file
38
test_dashboard.sh
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Test script for LLM Proxy Dashboard
|
||||||
|
|
||||||
|
echo "Building LLM Proxy Gateway..."
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Starting server in background..."
|
||||||
|
./target/release/llm-proxy &
|
||||||
|
SERVER_PID=$!
|
||||||
|
|
||||||
|
# Wait for server to start
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Testing dashboard endpoints..."
|
||||||
|
|
||||||
|
# Test health endpoint
|
||||||
|
echo "1. Testing health endpoint:"
|
||||||
|
curl -s http://localhost:8080/health
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "2. Testing dashboard static files:"
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "3. Testing API endpoints:"
|
||||||
|
curl -s http://localhost:8080/api/auth/status | jq . 2>/dev/null || echo "JSON response received"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Dashboard should be available at: http://localhost:8080"
|
||||||
|
echo "Default login: admin / admin123"
|
||||||
|
echo ""
|
||||||
|
echo "Press Ctrl+C to stop the server"
|
||||||
|
|
||||||
|
# Keep script running
|
||||||
|
wait $SERVER_PID
|
||||||
75
test_server.sh
Executable file
75
test_server.sh
Executable file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Test script for LLM Proxy Gateway
|
||||||
|
|
||||||
|
echo "Building LLM Proxy Gateway..."
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Build failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Build successful!"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Project Structure Summary:"
|
||||||
|
echo "=========================="
|
||||||
|
echo "Core Components:"
|
||||||
|
echo " - main.rs: Application entry point with server setup"
|
||||||
|
echo " - config/: Configuration management"
|
||||||
|
echo " - server/: API route handlers"
|
||||||
|
echo " - auth/: Bearer token authentication"
|
||||||
|
echo " - database/: SQLite database setup"
|
||||||
|
echo " - models/: Data structures (OpenAI-compatible)"
|
||||||
|
echo " - providers/: LLM provider implementations (OpenAI, Gemini, DeepSeek, Grok)"
|
||||||
|
echo " - errors/: Custom error types"
|
||||||
|
echo " - dashboard/: Admin dashboard with WebSocket support"
|
||||||
|
echo " - logging/: Request logging middleware"
|
||||||
|
echo " - state/: Shared application state"
|
||||||
|
echo " - multimodal/: Image processing support (basic structure)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Key Features Implemented:"
|
||||||
|
echo "=========================="
|
||||||
|
echo "✓ OpenAI-compatible API endpoint (/v1/chat/completions)"
|
||||||
|
echo "✓ Bearer token authentication"
|
||||||
|
echo "✓ SQLite database for request tracking"
|
||||||
|
echo "✓ Request logging with token/cost calculation"
|
||||||
|
echo "✓ Provider abstraction layer"
|
||||||
|
echo "✓ Admin dashboard with real-time monitoring"
|
||||||
|
echo "✓ WebSocket support for live updates"
|
||||||
|
echo "✓ Configuration management (config.toml, .env, env vars)"
|
||||||
|
echo "✓ Multimodal support structure (images)"
|
||||||
|
echo "✓ Error handling with proper HTTP status codes"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Next Steps Needed:"
|
||||||
|
echo "=================="
|
||||||
|
echo "1. Add API keys to .env file:"
|
||||||
|
echo " OPENAI_API_KEY=your_key_here"
|
||||||
|
echo " GEMINI_API_KEY=your_key_here"
|
||||||
|
echo " DEEPSEEK_API_KEY=your_key_here"
|
||||||
|
echo " GROK_API_KEY=your_key_here (optional)"
|
||||||
|
echo ""
|
||||||
|
echo "2. Create config.toml for custom configuration (optional)"
|
||||||
|
echo ""
|
||||||
|
echo "3. Run the server:"
|
||||||
|
echo " cargo run"
|
||||||
|
echo ""
|
||||||
|
echo "4. Access dashboard at: http://localhost:8080"
|
||||||
|
echo ""
|
||||||
|
echo "5. Test API with curl:"
|
||||||
|
echo " curl -X POST http://localhost:8080/v1/chat/completions \\"
|
||||||
|
echo " -H 'Authorization: Bearer your_token' \\"
|
||||||
|
echo " -H 'Content-Type: application/json' \\"
|
||||||
|
echo " -d '{\"model\": \"gpt-4\", \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]}'"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Deployment Notes:"
|
||||||
|
echo "================="
|
||||||
|
echo "Memory: Designed for 512MB RAM (LXC container)"
|
||||||
|
echo "Database: SQLite (./data/llm_proxy.db)"
|
||||||
|
echo "Port: 8080 (configurable)"
|
||||||
|
echo "Authentication: Single Bearer token (configurable)"
|
||||||
|
echo "Providers: OpenAI, Gemini, DeepSeek, Grok (disabled by default)"
|
||||||
188
tests/integration_tests.rs.bak
Normal file
188
tests/integration_tests.rs.bak
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
// Integration tests for LLM Proxy Gateway
|
||||||
|
|
||||||
|
use llm_proxy::config::Config;
|
||||||
|
use llm_proxy::database::Database;
|
||||||
|
use llm_proxy::state::AppState;
|
||||||
|
use llm_proxy::rate_limiting::RateLimitManager;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_config_loading() {
|
||||||
|
// Create a temporary config file
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let config_path = temp_dir.path().join("config.toml");
|
||||||
|
|
||||||
|
let config_content = r#"
|
||||||
|
[server]
|
||||||
|
port = 8080
|
||||||
|
host = "0.0.0.0"
|
||||||
|
|
||||||
|
[database]
|
||||||
|
path = "./data/test.db"
|
||||||
|
max_connections = 5
|
||||||
|
|
||||||
|
[providers.openai]
|
||||||
|
enabled = true
|
||||||
|
base_url = "https://api.openai.com/v1"
|
||||||
|
|
||||||
|
[providers.gemini]
|
||||||
|
enabled = true
|
||||||
|
base_url = "https://generativelanguage.googleapis.com/v1"
|
||||||
|
|
||||||
|
[providers.deepseek]
|
||||||
|
enabled = true
|
||||||
|
base_url = "https://api.deepseek.com"
|
||||||
|
|
||||||
|
[providers.grok]
|
||||||
|
enabled = false
|
||||||
|
base_url = "https://api.x.ai/v1"
|
||||||
|
|
||||||
|
[model_mapping]
|
||||||
|
"gpt-*" = "openai"
|
||||||
|
"gemini-*" = "gemini"
|
||||||
|
"deepseek-*" = "deepseek"
|
||||||
|
"grok-*" = "grok"
|
||||||
|
|
||||||
|
[pricing]
|
||||||
|
openai = { input = 0.01, output = 0.03 }
|
||||||
|
gemini = { input = 0.0005, output = 0.0015 }
|
||||||
|
deepseek = { input = 0.00014, output = 0.00028 }
|
||||||
|
grok = { input = 0.001, output = 0.003 }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fs::write(&config_path, config_content).unwrap();
|
||||||
|
|
||||||
|
// Test loading config
|
||||||
|
let config = Config::load_from_path(&config_path);
|
||||||
|
assert!(config.is_ok());
|
||||||
|
|
||||||
|
let config = config.unwrap();
|
||||||
|
assert_eq!(config.server.port, 8080);
|
||||||
|
assert!(config.providers.openai.is_some());
|
||||||
|
assert!(config.providers.grok.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_database_initialization() {
|
||||||
|
// Create a temporary database file
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let db_path = temp_dir.path().join("test.db");
|
||||||
|
|
||||||
|
// Test database initialization
|
||||||
|
let database = Database::new(&db_path).await;
|
||||||
|
assert!(database.is_ok());
|
||||||
|
|
||||||
|
let database = database.unwrap();
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
let test_result = database.test_connection().await;
|
||||||
|
assert!(test_result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_provider_manager() {
|
||||||
|
// Create a provider manager
|
||||||
|
use llm_proxy::providers::{ProviderManager, Provider};
|
||||||
|
use llm_proxy::config::OpenAIConfig;
|
||||||
|
|
||||||
|
let mut manager = ProviderManager::new();
|
||||||
|
assert_eq!(manager.providers.len(), 0);
|
||||||
|
|
||||||
|
// Test adding providers (we can't actually add real providers without API keys)
|
||||||
|
// This test just verifies the manager structure works
|
||||||
|
assert!(manager.get_provider_for_model("gpt-4").is_none());
|
||||||
|
assert!(manager.get_provider("openai").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_rate_limit_manager() {
|
||||||
|
let manager = RateLimitManager::new(60, 10);
|
||||||
|
|
||||||
|
// Test client rate limiting
|
||||||
|
let allowed = manager.check_request("test-client").await;
|
||||||
|
assert!(allowed); // First request should be allowed
|
||||||
|
|
||||||
|
// Test provider circuit breaker
|
||||||
|
let allowed = manager.check_provider("openai").await;
|
||||||
|
assert!(allowed); // Circuit should be closed initially
|
||||||
|
|
||||||
|
// Record some failures
|
||||||
|
manager.record_provider_failure("openai").await;
|
||||||
|
manager.record_provider_failure("openai").await;
|
||||||
|
manager.record_provider_failure("openai").await;
|
||||||
|
manager.record_provider_failure("openai").await;
|
||||||
|
manager.record_provider_failure("openai").await;
|
||||||
|
|
||||||
|
// After 5 failures, circuit should be open
|
||||||
|
let allowed = manager.check_provider("openai").await;
|
||||||
|
assert!(!allowed); // Circuit should be open
|
||||||
|
|
||||||
|
// Record success to close circuit
|
||||||
|
manager.record_provider_success("openai").await;
|
||||||
|
manager.record_provider_success("openai").await;
|
||||||
|
manager.record_provider_success("openai").await;
|
||||||
|
|
||||||
|
// After 3 successes in half-open state, circuit should be closed
|
||||||
|
let allowed = manager.check_provider("openai").await;
|
||||||
|
assert!(allowed); // Circuit should be closed again
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_app_state_creation() {
|
||||||
|
// Create a temporary database
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let db_path = temp_dir.path().join("test.db");
|
||||||
|
|
||||||
|
let database = Database::new(&db_path).await.unwrap();
|
||||||
|
|
||||||
|
// Test AppState creation using test utilities
|
||||||
|
use llm_proxy::test_utils::create_test_state;
|
||||||
|
let state = create_test_state().await;
|
||||||
|
|
||||||
|
// Verify state components are initialized
|
||||||
|
assert!(state.database.test_connection().await.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multimodal_image_converter() {
|
||||||
|
use llm_proxy::multimodal::{ImageConverter, ImageInput};
|
||||||
|
|
||||||
|
// Test model detection
|
||||||
|
assert!(ImageConverter::model_supports_multimodal("gpt-4-vision-preview"));
|
||||||
|
assert!(ImageConverter::model_supports_multimodal("gemini-pro-vision"));
|
||||||
|
assert!(!ImageConverter::model_supports_multimodal("gpt-3.5-turbo"));
|
||||||
|
assert!(!ImageConverter::model_supports_multimodal("gemini-pro"));
|
||||||
|
|
||||||
|
// Test data URL parsing (utility function)
|
||||||
|
let test_url = "data:image/jpeg;base64,SGVsbG8gV29ybGQ=";
|
||||||
|
let parts: Vec<&str> = test_url[5..].split(";base64,").collect();
|
||||||
|
assert_eq!(parts.len(), 2);
|
||||||
|
assert_eq!(parts[0], "image/jpeg");
|
||||||
|
assert_eq!(parts[1], "SGVsbG8gV29ybGQ=");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_error_conversions() {
|
||||||
|
use llm_proxy::errors::AppError;
|
||||||
|
use anyhow::anyhow;
|
||||||
|
|
||||||
|
// Test anyhow error conversion
|
||||||
|
let anyhow_error = anyhow!("Test error");
|
||||||
|
let app_error: AppError = anyhow_error.into();
|
||||||
|
|
||||||
|
match app_error {
|
||||||
|
AppError::InternalError(msg) => assert_eq!(msg, "Test error"),
|
||||||
|
_ => panic!("Expected InternalError"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test sqlx error conversion
|
||||||
|
use sqlx::Error as SqlxError;
|
||||||
|
let sqlx_error = SqlxError::PoolClosed;
|
||||||
|
let app_error: AppError = sqlx_error.into();
|
||||||
|
|
||||||
|
match app_error {
|
||||||
|
AppError::DatabaseError(msg) => assert!(msg.contains("pool closed")),
|
||||||
|
_ => panic!("Expected DatabaseError"),
|
||||||
|
}
|
||||||
|
}
|
||||||
0
tests/streaming_test.rs
Normal file
0
tests/streaming_test.rs
Normal file
Reference in New Issue
Block a user