Compare commits
2 Commits
b1363aeab9
...
6573e15ba4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6573e15ba4 | ||
|
|
bef36c7ca2 |
27
Dockerfile
27
Dockerfile
@@ -1,27 +0,0 @@
|
|||||||
FROM python:3.13-slim as base
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY pyproject.toml ./
|
|
||||||
COPY src/ ./src/
|
|
||||||
|
|
||||||
ARG VERSION=0.0.0+docker
|
|
||||||
ENV SETUPTOOLS_SCM_PRETEND_VERSION="${VERSION}"
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip && \
|
|
||||||
pip install --no-cache-dir .[all,gateway]
|
|
||||||
|
|
||||||
RUN useradd -m -u 1000 gateway && \
|
|
||||||
chown -R gateway:gateway /app
|
|
||||||
|
|
||||||
USER gateway
|
|
||||||
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
|
||||||
|
|
||||||
ENV GATEWAY_HOST=0.0.0.0
|
|
||||||
ENV GATEWAY_PORT=8000
|
|
||||||
|
|
||||||
CMD ["any-llm-gateway", "serve"]
|
|
||||||
File diff suppressed because it is too large
Load Diff
440
README.md
440
README.md
@@ -1,11 +1,16 @@
|
|||||||
# Any-LLM Gateway + Laravel Admin
|
# Laravel LLM Gateway
|
||||||
|
|
||||||
Vollständiges Docker-Setup mit:
|
Ein umfassendes Laravel-basiertes LLM Gateway System mit Multi-Provider-Support, Kosten-Tracking, Budget-Management und Admin-Interface.
|
||||||
- **Any-LLM Gateway** (API Gateway für LLMs)
|
|
||||||
- **Laravel Admin Panel** (Verwaltungsoberfläche)
|
## 🎯 Hauptfeatures
|
||||||
- **PostgreSQL** (Datenbank)
|
|
||||||
- **Adminer** (Datenbank-Management-Tool)
|
- **Multi-Provider Support**: OpenAI, Anthropic, DeepSeek, Google Gemini, Mistral AI
|
||||||
- **Gateway Tester** (Test-Oberfläche)
|
- **Per-User API Keys**: Verschlüsselte Speicherung von Provider-Credentials pro Benutzer
|
||||||
|
- **Kosten-Tracking**: Detaillierte Verfolgung von Token-Nutzung und Kosten
|
||||||
|
- **Budget-Management**: Flexible Budget-Limits und Benachrichtigungen
|
||||||
|
- **Rate Limiting**: Schutz vor Überlastung
|
||||||
|
- **Admin-Interface**: Umfassende Verwaltungsoberfläche mit Laravel/Livewire
|
||||||
|
- **OpenAI-kompatible API**: Standard-konforme Endpoints
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -13,24 +18,19 @@ Vollständiges Docker-Setup mit:
|
|||||||
|
|
||||||
### Voraussetzungen
|
### Voraussetzungen
|
||||||
- Docker & Docker Compose installiert
|
- Docker & Docker Compose installiert
|
||||||
- Ports 80, 8000, 8080, 8081 verfügbar
|
- Port 80 und 8081 verfügbar
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/any-llm
|
cd /opt/laravel-llm
|
||||||
./setup-laravel.sh
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Setup-Script führt automatisch aus:
|
Das System startet automatisch:
|
||||||
1. ✅ Laravel Installation
|
- Laravel Anwendung auf Port 80
|
||||||
2. ✅ Livewire & Breeze Setup
|
- MariaDB 11.4 Datenbank
|
||||||
3. ✅ Docker Container Build & Start
|
- phpMyAdmin auf Port 8081
|
||||||
4. ✅ Datenbank-Migrationen
|
|
||||||
5. ✅ Admin-User Erstellung
|
|
||||||
6. ✅ Assets-Kompilierung
|
|
||||||
|
|
||||||
**⏱️ Dauer: ca. 5-10 Minuten**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,25 +40,22 @@ Das Setup-Script führt automatisch aus:
|
|||||||
|
|
||||||
| Service | URL | Beschreibung |
|
| Service | URL | Beschreibung |
|
||||||
|---------|-----|--------------|
|
|---------|-----|--------------|
|
||||||
| **Laravel Admin** | http://localhost:80 | Verwaltungsoberfläche |
|
| **Laravel Admin** | http://localhost:80 | Admin-Interface |
|
||||||
| **Gateway API** | http://localhost:8000 | Any-LLM Gateway |
|
| **phpMyAdmin** | http://localhost:8081 | Datenbank-Management |
|
||||||
| **Gateway Tester** | http://localhost:8080 | Test-Interface |
|
|
||||||
| **Adminer** | http://localhost:8081 | PostgreSQL Management |
|
|
||||||
|
|
||||||
### Login-Daten
|
### Login-Daten
|
||||||
|
|
||||||
#### Laravel Admin
|
#### Laravel Admin
|
||||||
```
|
```
|
||||||
Email: admin@example.com
|
Email: admin@laravel-llm.local
|
||||||
Password: password123
|
Password: [Dein Admin-Passwort]
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Adminer (PostgreSQL)
|
#### phpMyAdmin (MariaDB)
|
||||||
```
|
```
|
||||||
System: PostgreSQL
|
Server: mariadb
|
||||||
Server: postgres
|
Username: root
|
||||||
Username: gateway
|
Password: rootpass
|
||||||
Password: gateway
|
|
||||||
Database: gateway
|
Database: gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -67,28 +64,63 @@ Database: gateway
|
|||||||
## 📁 Projekt-Struktur
|
## 📁 Projekt-Struktur
|
||||||
|
|
||||||
```
|
```
|
||||||
/opt/any-llm/
|
/opt/laravel-llm/
|
||||||
├── config.yml # Gateway Konfiguration
|
├── docker-compose.yml # Docker Services Definition
|
||||||
├── docker-compose.yml # Docker Services
|
├── backup_*.sql # Datenbank-Backups
|
||||||
├── setup-laravel.sh # Setup Script
|
|
||||||
│
|
│
|
||||||
├── laravel/ # Laravel Docker Config
|
├── laravel/ # Laravel Docker Config
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile # PHP 8.3 + Nginx
|
||||||
│ ├── nginx.conf
|
│ ├── nginx.conf # Webserver Config
|
||||||
│ ├── supervisord.conf
|
│ ├── supervisord.conf # Process Manager
|
||||||
│ └── php.ini
|
│ └── php.ini # PHP Einstellungen
|
||||||
│
|
│
|
||||||
├── laravel-app/ # Laravel Projekt (wird generiert)
|
└── laravel-app/ # Laravel Anwendung
|
||||||
│ ├── app/
|
├── app/
|
||||||
│ ├── database/
|
│ ├── Http/Controllers/ # Admin Controllers
|
||||||
│ ├── resources/
|
│ ├── Models/ # Eloquent Models
|
||||||
│ └── ...
|
│ ├── Services/LLM/ # Provider Services
|
||||||
│
|
│ └── ...
|
||||||
├── web/ # Gateway Tester
|
├── database/
|
||||||
│ ├── index.html
|
│ └── migrations/ # Datenbank Schema
|
||||||
│ └── default.conf
|
├── resources/
|
||||||
│
|
│ └── views/ # Blade Templates
|
||||||
└── LARAVEL_IMPLEMENTATION.md # Detailliertes Implementierungskonzept
|
└── routes/
|
||||||
|
└── web.php # Admin Routes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄️ Datenbank-Schema
|
||||||
|
|
||||||
|
### Haupttabellen
|
||||||
|
|
||||||
|
| Tabelle | Beschreibung |
|
||||||
|
|---------|--------------|
|
||||||
|
| `admins` | Admin-Benutzer für das Interface |
|
||||||
|
| `users` | Gateway-Benutzer (API-Nutzer) |
|
||||||
|
| `user_provider_keys` | Verschlüsselte Provider API-Keys |
|
||||||
|
| `usage_logs` | Request-Tracking und Kosten |
|
||||||
|
| `budgets` | Budget-Definitionen |
|
||||||
|
| `budget_reset_logs` | Budget-Historie |
|
||||||
|
| `model_pricing` | Modell-Kosten-Konfiguration |
|
||||||
|
|
||||||
|
### Datenbank-Zugriff
|
||||||
|
|
||||||
|
**Via phpMyAdmin:**
|
||||||
|
```
|
||||||
|
http://localhost:8081
|
||||||
|
Server: mariadb
|
||||||
|
User: root
|
||||||
|
Password: rootpass
|
||||||
|
```
|
||||||
|
|
||||||
|
**Via CLI:**
|
||||||
|
```bash
|
||||||
|
docker compose exec mariadb mysql -u gateway -pgateway gateway
|
||||||
|
|
||||||
|
# Beispiel-Queries
|
||||||
|
SELECT * FROM users;
|
||||||
|
SELECT * FROM usage_logs ORDER BY created_at DESC LIMIT 10;
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -98,157 +130,158 @@ Database: gateway
|
|||||||
### Container Management
|
### Container Management
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Alle Container starten
|
# Container starten
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# Alle Container stoppen
|
# Container stoppen
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
# Logs anzeigen
|
# Logs anzeigen
|
||||||
docker compose logs -f
|
|
||||||
|
|
||||||
# Logs eines bestimmten Services
|
|
||||||
docker compose logs -f laravel
|
docker compose logs -f laravel
|
||||||
docker compose logs -f gateway
|
|
||||||
|
|
||||||
# Container neu bauen
|
# Container neu bauen
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
|
||||||
# In Container einloggen
|
# In Laravel Container einloggen
|
||||||
docker compose exec laravel bash
|
docker compose exec laravel bash
|
||||||
docker compose exec postgres psql -U gateway -d gateway
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Laravel Commands (im Container)
|
### Laravel Artisan Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Artisan Commands
|
# Migrationen
|
||||||
docker compose exec laravel php artisan migrate
|
docker compose exec laravel php artisan migrate
|
||||||
docker compose exec laravel php artisan make:model ModelName
|
|
||||||
docker compose exec laravel php artisan make:controller ControllerName
|
|
||||||
|
|
||||||
# Composer
|
# Cache leeren
|
||||||
docker compose exec laravel composer install
|
docker compose exec laravel php artisan cache:clear
|
||||||
docker compose exec laravel composer require package-name
|
docker compose exec laravel php artisan config:clear
|
||||||
|
|
||||||
# NPM
|
# Queue Worker starten
|
||||||
docker compose exec laravel npm install
|
docker compose exec laravel php artisan queue:work
|
||||||
docker compose exec laravel npm run dev
|
|
||||||
docker compose exec laravel npm run build
|
|
||||||
|
|
||||||
# Tinker (Laravel REPL)
|
# Tinker (Laravel REPL)
|
||||||
docker compose exec laravel php artisan tinker
|
docker compose exec laravel php artisan tinker
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### Asset Compilation
|
||||||
|
|
||||||
## 🗄️ Datenbank
|
|
||||||
|
|
||||||
### Schema
|
|
||||||
|
|
||||||
Die Gateway-Datenbank enthält folgende Tabellen:
|
|
||||||
|
|
||||||
```
|
|
||||||
users → Gateway API Users
|
|
||||||
api_keys → Virtual Keys
|
|
||||||
usage_logs → Request Tracking
|
|
||||||
budgets → Budget Definitions
|
|
||||||
budget_reset_logs → Budget History
|
|
||||||
model_pricing → Model Cost Configuration
|
|
||||||
admins → Laravel Admin Users (neu)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Zugriff
|
|
||||||
|
|
||||||
**Via Adminer Web-Interface:**
|
|
||||||
- http://localhost:8081
|
|
||||||
- Login mit oben genannten Credentials
|
|
||||||
|
|
||||||
**Via CLI:**
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec postgres psql -U gateway -d gateway
|
# Development (mit Hot Reload)
|
||||||
|
docker compose exec laravel npm run dev
|
||||||
|
|
||||||
# Beispiel-Queries
|
# Production Build
|
||||||
SELECT * FROM users;
|
docker compose exec laravel npm run build
|
||||||
SELECT * FROM usage_logs ORDER BY timestamp DESC LIMIT 10;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ Entwicklung
|
## 🛠️ Entwicklung
|
||||||
|
|
||||||
### Laravel Development
|
### Admin-Interface Features
|
||||||
|
|
||||||
```bash
|
Das Admin-Interface unter http://localhost:80 bietet:
|
||||||
# In Laravel Container einloggen
|
|
||||||
docker compose exec laravel bash
|
|
||||||
|
|
||||||
# Routes anzeigen
|
1. **Dashboard**
|
||||||
php artisan route:list
|
- Übersicht über Nutzungsstatistiken
|
||||||
|
- Kosten-Trends (Chart.js)
|
||||||
|
- Provider-Verteilung
|
||||||
|
- Aktive Benutzer
|
||||||
|
|
||||||
# Model erstellen
|
2. **Gateway Users**
|
||||||
php artisan make:model MyModel -m
|
- Benutzerverwaltung
|
||||||
|
- API-Key Management
|
||||||
|
- Pro-User Provider-Credentials
|
||||||
|
|
||||||
# Controller erstellen
|
3. **API Keys**
|
||||||
php artisan make:controller MyController --resource
|
- Virtual Keys erstellen/löschen
|
||||||
|
- Key-Testing
|
||||||
|
- Nutzungsstatistiken
|
||||||
|
|
||||||
# Livewire Component erstellen
|
4. **Budgets**
|
||||||
php artisan make:livewire MyComponent
|
- Budget-Limits definieren
|
||||||
```
|
- Reset-Zeiträume (täglich/wöchentlich/monatlich)
|
||||||
|
- Benachrichtigungen
|
||||||
|
|
||||||
### Frontend Development
|
5. **Usage Logs**
|
||||||
|
- Request-Historie
|
||||||
|
- Filter & Export (CSV)
|
||||||
|
- Kosten-Analyse
|
||||||
|
|
||||||
```bash
|
6. **Model Pricing**
|
||||||
# NPM Dev Server (mit Hot Reload)
|
- Preis-Konfiguration
|
||||||
docker compose exec laravel npm run dev
|
- Dynamische Model-Liste
|
||||||
|
- Kosten-Rechner
|
||||||
|
|
||||||
# Production Build
|
### LLM Provider Services
|
||||||
docker compose exec laravel npm run build
|
|
||||||
|
|
||||||
# Tailwind JIT Mode
|
Das System unterstützt folgende Provider mit dynamischer Model-Discovery:
|
||||||
# → Läuft automatisch mit npm run dev
|
|
||||||
```
|
- **OpenAI**: GPT-3.5, GPT-4, GPT-4 Turbo
|
||||||
|
- **Anthropic**: Claude 3 (Haiku, Sonnet, Opus)
|
||||||
|
- **DeepSeek**: DeepSeek Chat Modelle
|
||||||
|
- **Google Gemini**: Gemini Pro, Flash
|
||||||
|
- **Mistral AI**: Mistral Large, Medium, Small
|
||||||
|
|
||||||
|
Alle Provider-Services befinden sich in `app/Services/LLM/`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📝 Nächste Schritte
|
## 🔐 Sicherheit
|
||||||
|
|
||||||
### 1. Models erstellen
|
### Production Checklist
|
||||||
|
|
||||||
Folge dem Implementierungskonzept in `LARAVEL_IMPLEMENTATION.md`:
|
Vor dem Production-Einsatz:
|
||||||
|
|
||||||
```bash
|
1. ✅ `.env` Konfiguration:
|
||||||
docker compose exec laravel php artisan make:model GatewayUser
|
```env
|
||||||
docker compose exec laravel php artisan make:model ApiKey
|
APP_ENV=production
|
||||||
docker compose exec laravel php artisan make:model UsageLog
|
APP_DEBUG=false
|
||||||
docker compose exec laravel php artisan make:model Budget
|
APP_KEY=... (sicher generieren!)
|
||||||
docker compose exec laravel php artisan make:model ModelPricing
|
```
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Controllers implementieren
|
2. ✅ Admin-Passwort ändern
|
||||||
|
|
||||||
```bash
|
3. ✅ MariaDB Root-Passwort ändern
|
||||||
docker compose exec laravel php artisan make:controller DashboardController
|
|
||||||
docker compose exec laravel php artisan make:controller GatewayUserController --resource
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Views erstellen
|
4. ✅ phpMyAdmin deaktivieren oder absichern
|
||||||
|
|
||||||
Die Views werden in `laravel-app/resources/views/` erstellt.
|
5. ✅ SSL/TLS einrichten
|
||||||
|
|
||||||
Struktur:
|
6. ✅ Laravel Caches aktivieren:
|
||||||
```
|
```bash
|
||||||
resources/views/
|
php artisan config:cache
|
||||||
├── layouts/
|
php artisan route:cache
|
||||||
│ ├── app.blade.php
|
php artisan view:cache
|
||||||
│ └── navigation.blade.php
|
php artisan optimize
|
||||||
├── dashboard.blade.php
|
```
|
||||||
├── gateway-users/
|
|
||||||
│ ├── index.blade.php
|
### API-Key Verschlüsselung
|
||||||
│ ├── show.blade.php
|
|
||||||
│ └── ...
|
Provider API-Keys werden verschlüsselt in der Datenbank gespeichert:
|
||||||
└── ...
|
- Verwendung von Laravel's Encryption
|
||||||
```
|
- Basiert auf APP_KEY
|
||||||
|
- Automatische Ver-/Entschlüsselung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Monitoring & Analytics
|
||||||
|
|
||||||
|
### Dashboard Metriken
|
||||||
|
|
||||||
|
- Gesamte Requests
|
||||||
|
- Token-Nutzung (Input/Output)
|
||||||
|
- Gesamtkosten
|
||||||
|
- Requests pro Provider
|
||||||
|
- Top-Nutzer
|
||||||
|
- Kosten-Trends (Chart.js Visualisierung)
|
||||||
|
|
||||||
|
### Export Funktionen
|
||||||
|
|
||||||
|
Usage Logs können als CSV exportiert werden mit:
|
||||||
|
- Zeitraum-Filter
|
||||||
|
- Provider-Filter
|
||||||
|
- Benutzer-Filter
|
||||||
|
- Kosten-Zusammenfassung
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -259,12 +292,23 @@ resources/views/
|
|||||||
```bash
|
```bash
|
||||||
# Logs prüfen
|
# Logs prüfen
|
||||||
docker compose logs laravel
|
docker compose logs laravel
|
||||||
|
docker compose logs mariadb
|
||||||
|
|
||||||
# Container neu bauen
|
# Container neu bauen
|
||||||
docker compose down
|
docker compose down
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Datenbank Connection Fehler
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# MariaDB Status prüfen
|
||||||
|
docker compose ps mariadb
|
||||||
|
|
||||||
|
# Connection testen
|
||||||
|
docker compose exec laravel php artisan migrate:status
|
||||||
|
```
|
||||||
|
|
||||||
### Permissions Fehler
|
### Permissions Fehler
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -272,25 +316,6 @@ docker compose up -d --build
|
|||||||
docker compose exec laravel chmod -R 777 storage bootstrap/cache
|
docker compose exec laravel chmod -R 777 storage bootstrap/cache
|
||||||
```
|
```
|
||||||
|
|
||||||
### Port bereits belegt
|
|
||||||
|
|
||||||
Ports in `docker-compose.yml` anpassen:
|
|
||||||
```yaml
|
|
||||||
laravel:
|
|
||||||
ports:
|
|
||||||
- "8001:80" # Statt 80:80
|
|
||||||
```
|
|
||||||
|
|
||||||
### Datenbank Connection Fehler
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Prüfe ob PostgreSQL läuft
|
|
||||||
docker compose ps postgres
|
|
||||||
|
|
||||||
# Teste Connection
|
|
||||||
docker compose exec laravel php artisan migrate:status
|
|
||||||
```
|
|
||||||
|
|
||||||
### Assets werden nicht geladen
|
### Assets werden nicht geladen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -303,69 +328,50 @@ docker compose exec laravel php artisan storage:link
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔐 Sicherheit
|
## 📚 Technologie-Stack
|
||||||
|
|
||||||
### Production Checklist
|
- **Framework**: Laravel 11.x
|
||||||
|
- **Frontend**: Livewire 3.x + Tailwind CSS 3.x
|
||||||
Vor dem Production-Deployment:
|
- **Datenbank**: MariaDB 11.4
|
||||||
|
- **Webserver**: Nginx + PHP-FPM 8.3
|
||||||
1. ✅ `.env` Werte ändern:
|
- **Charts**: Chart.js
|
||||||
```
|
- **Container**: Docker + Docker Compose
|
||||||
APP_ENV=production
|
|
||||||
APP_DEBUG=false
|
|
||||||
APP_KEY=... (neu generieren!)
|
|
||||||
```
|
|
||||||
|
|
||||||
2. ✅ Starkes Admin-Passwort setzen
|
|
||||||
|
|
||||||
3. ✅ PostgreSQL Passwort ändern
|
|
||||||
|
|
||||||
4. ✅ Adminer auf localhost beschränken oder deaktivieren
|
|
||||||
|
|
||||||
5. ✅ SSL/TLS einrichten (Let's Encrypt)
|
|
||||||
|
|
||||||
6. ✅ Laravel Caches aktivieren:
|
|
||||||
```bash
|
|
||||||
php artisan config:cache
|
|
||||||
php artisan route:cache
|
|
||||||
php artisan view:cache
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 Dokumentation
|
## 🔗 Nützliche Links
|
||||||
|
|
||||||
- **Implementierungskonzept**: `LARAVEL_IMPLEMENTATION.md`
|
|
||||||
- **Any-LLM Gateway**: https://github.com/mozilla-ai/any-llm
|
|
||||||
- **Laravel Docs**: https://laravel.com/docs
|
- **Laravel Docs**: https://laravel.com/docs
|
||||||
- **Livewire Docs**: https://livewire.laravel.com
|
- **Livewire Docs**: https://livewire.laravel.com
|
||||||
- **Tailwind CSS**: https://tailwindcss.com
|
- **Tailwind CSS**: https://tailwindcss.com
|
||||||
|
- **Chart.js**: https://www.chartjs.org
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🆘 Support
|
## 📝 System-Status
|
||||||
|
|
||||||
Bei Fragen oder Problemen:
|
### ✅ Implementiert
|
||||||
|
|
||||||
1. Logs prüfen: `docker compose logs -f`
|
- [x] Docker-Setup (Laravel + MariaDB + phpMyAdmin)
|
||||||
2. Container Status: `docker compose ps`
|
- [x] Admin-Authentifizierung
|
||||||
3. Implementierungskonzept lesen: `LARAVEL_IMPLEMENTATION.md`
|
- [x] Gateway User Management
|
||||||
|
- [x] API Key Management mit Testing
|
||||||
|
- [x] Budget-System mit Limits
|
||||||
|
- [x] Usage Logs mit CSV Export
|
||||||
|
- [x] Model Pricing Management
|
||||||
|
- [x] Dashboard mit Statistiken
|
||||||
|
- [x] Multi-Provider Support (5 Provider)
|
||||||
|
- [x] Verschlüsselte Credential-Speicherung
|
||||||
|
- [x] Dynamische Model-Discovery
|
||||||
|
|
||||||
|
### 🚧 Geplant
|
||||||
|
|
||||||
|
- [ ] API Gateway Endpoints (OpenAI-kompatibel)
|
||||||
|
- [ ] Rate Limiting Implementation
|
||||||
|
- [ ] Email-Benachrichtigungen
|
||||||
|
- [ ] Erweiterte Analytics
|
||||||
|
- [ ] API-Dokumentation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Checkliste
|
**Entwickelt von Wilfried Trinkl | Laravel LLM Gateway** 🚀
|
||||||
|
|
||||||
- [ ] Setup Script ausgeführt
|
|
||||||
- [ ] Laravel läuft auf http://localhost:80
|
|
||||||
- [ ] Admin-Login funktioniert
|
|
||||||
- [ ] Adminer erreichbar (http://localhost:8081)
|
|
||||||
- [ ] Gateway API funktioniert (http://localhost:8000)
|
|
||||||
- [ ] Models erstellt (siehe Implementierungskonzept)
|
|
||||||
- [ ] Controllers implementiert
|
|
||||||
- [ ] Dashboard Views erstellt
|
|
||||||
- [ ] Statistiken funktionieren
|
|
||||||
- [ ] Production-ready gemacht
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Viel Erfolg mit der Entwicklung! 🚀**
|
|
||||||
40
config.yml
40
config.yml
@@ -1,40 +0,0 @@
|
|||||||
# Configuration for any-llm-gateway
|
|
||||||
|
|
||||||
# Database connection URL
|
|
||||||
database_url: "postgresql://gateway:gateway@postgres:5432/gateway"
|
|
||||||
|
|
||||||
# Server configuration - if you want to use a different port, change it here and in the docker-compose.yml file.
|
|
||||||
host: "0.0.0.0"
|
|
||||||
port: 8000
|
|
||||||
|
|
||||||
# Master key for protecting key macnagement endpoints
|
|
||||||
master_key: bdab4b5261d6e6ed7173c999ababd7c66066d76d3a06c8506a880ecdcfb41bcd
|
|
||||||
|
|
||||||
# Pre-configured provider credentials
|
|
||||||
providers:
|
|
||||||
# Uncomment and add your API keys for the providers you want to use:
|
|
||||||
|
|
||||||
openai:
|
|
||||||
api_key: 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
|
|
||||||
api_base: "https://api.openai.com/v1" # optional
|
|
||||||
|
|
||||||
anthropic:
|
|
||||||
api_key: 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
|
|
||||||
|
|
||||||
# mistral:
|
|
||||||
# api_key: YOUR_MISTRAL_API_KEY_HERE
|
|
||||||
|
|
||||||
# Add more providers as needed...
|
|
||||||
|
|
||||||
# Model pricing configuration (optional but necessary for price tracking)
|
|
||||||
# Format: "provider:model" -> input/output price per million tokens
|
|
||||||
# Database pricing takes precedence - config only sets initial values
|
|
||||||
# Prices are in USD per million tokens
|
|
||||||
pricing: {}
|
|
||||||
# See https://cloud.google.com/vertex-ai/generative-ai/pricing
|
|
||||||
# vertexai:Qwen3-235B-A22B-Instruct-2507:
|
|
||||||
# input_price_per_million: 0.25
|
|
||||||
# output_price_per_million: 1.00
|
|
||||||
# openai:gpt-5:
|
|
||||||
# input_price_per_million: 0.25
|
|
||||||
# output_price_per_million: 1.00
|
|
||||||
@@ -1,57 +1,29 @@
|
|||||||
services:
|
services:
|
||||||
gateway:
|
# MariaDB Database
|
||||||
# if pulling from ghcr.io, use the following instead, and comment out the build section:
|
mariadb:
|
||||||
image: ghcr.io/mozilla-ai/any-llm/gateway:latest
|
container_name: laravel-llm-mariadb
|
||||||
# build:
|
image: mariadb:11.4
|
||||||
# context: ..
|
|
||||||
# dockerfile: docker/Dockerfile
|
|
||||||
# args:
|
|
||||||
# VERSION: ${VERSION:-0.0.0+local}
|
|
||||||
# If you want to use a different port, change it here and in the config.yml file.
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
volumes:
|
|
||||||
- ./config.yml:/app/config.yml
|
|
||||||
command: ["any-llm-gateway", "serve", "--config", "/app/config.yml"]
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- any-llm-network
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=gateway
|
- MYSQL_ROOT_PASSWORD=rootpass
|
||||||
- POSTGRES_PASSWORD=gateway
|
- MYSQL_DATABASE=gateway
|
||||||
- POSTGRES_DB=gateway
|
- MYSQL_USER=gateway
|
||||||
|
- MYSQL_PASSWORD=gateway
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- mariadb_data:/var/lib/mysql
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U gateway"]
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- any-llm-network
|
- laravel-llm-network
|
||||||
|
|
||||||
web:
|
|
||||||
image: nginx:alpine
|
|
||||||
ports:
|
|
||||||
- "8080:80"
|
|
||||||
volumes:
|
|
||||||
- ./web:/usr/share/nginx/html:ro
|
|
||||||
- ./web/default.conf:/etc/nginx/conf.d/default.conf:ro
|
|
||||||
depends_on:
|
|
||||||
- gateway
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- any-llm-network
|
|
||||||
|
|
||||||
# Laravel Admin Panel
|
# Laravel Admin Panel
|
||||||
laravel:
|
laravel:
|
||||||
|
container_name: laravel-llm-app
|
||||||
build:
|
build:
|
||||||
context: ./laravel
|
context: ./laravel
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -63,36 +35,41 @@ services:
|
|||||||
- APP_ENV=local
|
- APP_ENV=local
|
||||||
- APP_DEBUG=true
|
- APP_DEBUG=true
|
||||||
- APP_KEY=base64:dXFQ1q9f0T9fNZGde+9h/JOsaBPPmGv5qzA87b9FQnQ=
|
- APP_KEY=base64:dXFQ1q9f0T9fNZGde+9h/JOsaBPPmGv5qzA87b9FQnQ=
|
||||||
- DB_CONNECTION=pgsql
|
- DB_CONNECTION=mysql
|
||||||
- DB_HOST=postgres
|
- DB_HOST=mariadb
|
||||||
- DB_PORT=5432
|
- DB_PORT=3306
|
||||||
- DB_DATABASE=gateway
|
- DB_DATABASE=gateway
|
||||||
- DB_USERNAME=gateway
|
- DB_USERNAME=gateway
|
||||||
- DB_PASSWORD=gateway
|
- DB_PASSWORD=gateway
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
mariadb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- any-llm-network
|
- laravel-llm-network
|
||||||
|
|
||||||
# Adminer - Database Management UI
|
# phpMyAdmin - Database Management UI
|
||||||
adminer:
|
phpmyadmin:
|
||||||
image: adminer:latest
|
container_name: laravel-llm-phpmyadmin
|
||||||
|
image: phpmyadmin:latest
|
||||||
ports:
|
ports:
|
||||||
- "8081:8080"
|
- "8081:80"
|
||||||
environment:
|
environment:
|
||||||
- ADMINER_DEFAULT_SERVER=postgres
|
- PMA_HOST=mariadb
|
||||||
- ADMINER_DESIGN=dracula
|
- PMA_PORT=3306
|
||||||
|
- PMA_USER=root
|
||||||
|
- PMA_PASSWORD=rootpass
|
||||||
|
- UPLOAD_LIMIT=300M
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
mariadb:
|
||||||
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- any-llm-network
|
- laravel-llm-network
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
mariadb_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
any-llm-network:
|
laravel-llm-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
27
laravel-app/app/Exceptions/InsufficientBudgetException.php
Normal file
27
laravel-app/app/Exceptions/InsufficientBudgetException.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class InsufficientBudgetException extends Exception
|
||||||
|
{
|
||||||
|
protected $code = 402; // Payment Required
|
||||||
|
|
||||||
|
public function __construct(string $message = "Insufficient budget", int $code = 402, ?Exception $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the exception as an HTTP response.
|
||||||
|
*/
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'budget_exceeded',
|
||||||
|
'message' => $this->getMessage(),
|
||||||
|
], $this->code);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
laravel-app/app/Exceptions/ProviderException.php
Normal file
25
laravel-app/app/Exceptions/ProviderException.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class ProviderException extends Exception
|
||||||
|
{
|
||||||
|
public function __construct(string $message = "Provider error", int $code = 500, ?Exception $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the exception as an HTTP response.
|
||||||
|
*/
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'provider_error',
|
||||||
|
'message' => $this->getMessage(),
|
||||||
|
], $this->code);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
laravel-app/app/Exceptions/RateLimitExceededException.php
Normal file
27
laravel-app/app/Exceptions/RateLimitExceededException.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class RateLimitExceededException extends Exception
|
||||||
|
{
|
||||||
|
protected $code = 429; // Too Many Requests
|
||||||
|
|
||||||
|
public function __construct(string $message = "Rate limit exceeded", int $code = 429, ?Exception $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the exception as an HTTP response.
|
||||||
|
*/
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'rate_limit_exceeded',
|
||||||
|
'message' => $this->getMessage(),
|
||||||
|
], $this->code);
|
||||||
|
}
|
||||||
|
}
|
||||||
478
laravel-app/app/Http/Controllers/Admin/CredentialController.php
Normal file
478
laravel-app/app/Http/Controllers/Admin/CredentialController.php
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\UserProviderCredential;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class CredentialController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of provider credentials.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = UserProviderCredential::with('user');
|
||||||
|
|
||||||
|
// Filter by provider
|
||||||
|
if ($request->has('provider') && $request->provider) {
|
||||||
|
$query->where('provider', $request->provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by user
|
||||||
|
if ($request->has('user_id') && $request->user_id) {
|
||||||
|
$query->where('user_id', $request->user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
if ($request->has('status')) {
|
||||||
|
switch ($request->status) {
|
||||||
|
case 'active':
|
||||||
|
$query->where('is_active', true);
|
||||||
|
break;
|
||||||
|
case 'inactive':
|
||||||
|
$query->where('is_active', false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
if ($request->has('search') && $request->search) {
|
||||||
|
$query->whereHas('user', function ($q) use ($request) {
|
||||||
|
$q->where('name', 'like', '%' . $request->search . '%')
|
||||||
|
->orWhere('email', 'like', '%' . $request->search . '%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
$sortBy = $request->get('sort_by', 'created_at');
|
||||||
|
$sortOrder = $request->get('sort_order', 'desc');
|
||||||
|
$query->orderBy($sortBy, $sortOrder);
|
||||||
|
|
||||||
|
$credentials = $query->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
// Get all users and providers for filters
|
||||||
|
$users = User::orderBy('name')->get();
|
||||||
|
$providers = ['openai', 'anthropic', 'mistral', 'gemini', 'deepseek'];
|
||||||
|
|
||||||
|
return view('admin.credentials.index', compact('credentials', 'users', 'providers'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new credential.
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$users = User::orderBy('name')->get();
|
||||||
|
$providers = [
|
||||||
|
'openai' => 'OpenAI',
|
||||||
|
'anthropic' => 'Anthropic (Claude)',
|
||||||
|
'mistral' => 'Mistral AI',
|
||||||
|
'gemini' => 'Google Gemini',
|
||||||
|
'deepseek' => 'DeepSeek'
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('admin.credentials.create', compact('users', 'providers'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created credential.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'user_id' => 'required|exists:users,id',
|
||||||
|
'provider' => 'required|in:openai,anthropic,mistral,gemini,deepseek',
|
||||||
|
'api_key' => 'required|string|min:10',
|
||||||
|
'organization_id' => 'nullable|string|max:255',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check for duplicate
|
||||||
|
$existing = UserProviderCredential::where('user_id', $validated['user_id'])
|
||||||
|
->where('provider', $validated['provider'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
return back()
|
||||||
|
->withInput()
|
||||||
|
->with('error', 'This user already has credentials for ' . ucfirst($validated['provider']) . '. Please edit the existing credentials instead.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create credential (encryption happens automatically in model)
|
||||||
|
$credential = UserProviderCredential::create([
|
||||||
|
'user_id' => $validated['user_id'],
|
||||||
|
'provider' => $validated['provider'],
|
||||||
|
'api_key' => $validated['api_key'],
|
||||||
|
'organization_id' => $validated['organization_id'] ?? null,
|
||||||
|
'is_active' => $validated['is_active'] ?? true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('admin.credentials.index')
|
||||||
|
->with('success', 'Provider credentials added successfully!');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to create provider credential', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'user_id' => $validated['user_id'],
|
||||||
|
'provider' => $validated['provider']
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()
|
||||||
|
->withInput()
|
||||||
|
->with('error', 'Failed to add credentials: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified credential.
|
||||||
|
*/
|
||||||
|
public function show(UserProviderCredential $credential)
|
||||||
|
{
|
||||||
|
$credential->load('user');
|
||||||
|
|
||||||
|
// Get usage statistics
|
||||||
|
$stats = [
|
||||||
|
'total_requests' => $credential->user->llmRequests()
|
||||||
|
->where('provider', $credential->provider)
|
||||||
|
->count(),
|
||||||
|
'total_cost' => $credential->user->llmRequests()
|
||||||
|
->where('provider', $credential->provider)
|
||||||
|
->sum('total_cost'),
|
||||||
|
'total_tokens' => $credential->user->llmRequests()
|
||||||
|
->where('provider', $credential->provider)
|
||||||
|
->sum('total_tokens'),
|
||||||
|
'last_30_days_requests' => $credential->user->llmRequests()
|
||||||
|
->where('provider', $credential->provider)
|
||||||
|
->where('created_at', '>=', now()->subDays(30))
|
||||||
|
->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('admin.credentials.show', compact('credential', 'stats'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for editing the specified credential.
|
||||||
|
*/
|
||||||
|
public function edit(UserProviderCredential $credential)
|
||||||
|
{
|
||||||
|
$credential->load('user');
|
||||||
|
|
||||||
|
$providers = [
|
||||||
|
'openai' => 'OpenAI',
|
||||||
|
'anthropic' => 'Anthropic (Claude)',
|
||||||
|
'mistral' => 'Mistral AI',
|
||||||
|
'gemini' => 'Google Gemini',
|
||||||
|
'deepseek' => 'DeepSeek'
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('admin.credentials.edit', compact('credential', 'providers'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified credential.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, UserProviderCredential $credential)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'api_key' => 'nullable|string|min:10',
|
||||||
|
'organization_id' => 'nullable|string|max:255',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Only update API key if provided
|
||||||
|
if (!empty($validated['api_key'])) {
|
||||||
|
$credential->api_key = $validated['api_key'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$credential->organization_id = $validated['organization_id'] ?? null;
|
||||||
|
$credential->is_active = $validated['is_active'] ?? true;
|
||||||
|
$credential->save();
|
||||||
|
|
||||||
|
return redirect()->route('admin.credentials.index')
|
||||||
|
->with('success', 'Provider credentials updated successfully!');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to update provider credential', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'credential_id' => $credential->id
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()
|
||||||
|
->withInput()
|
||||||
|
->with('error', 'Failed to update credentials: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified credential.
|
||||||
|
*/
|
||||||
|
public function destroy(UserProviderCredential $credential)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$provider = ucfirst($credential->provider);
|
||||||
|
$userName = $credential->user->name;
|
||||||
|
|
||||||
|
$credential->delete();
|
||||||
|
|
||||||
|
return redirect()->route('admin.credentials.index')
|
||||||
|
->with('success', "Provider credentials for {$provider} (User: {$userName}) deleted successfully!");
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to delete provider credential', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'credential_id' => $credential->id
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()
|
||||||
|
->with('error', 'Failed to delete credentials: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test the API key validity.
|
||||||
|
*/
|
||||||
|
public function test(UserProviderCredential $credential)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $this->testProviderApiKey($credential->provider, $credential->api_key);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'API key is valid and working!',
|
||||||
|
'details' => $result['details'] ?? null
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => $result['message'] ?? 'API key validation failed',
|
||||||
|
'error' => $result['error'] ?? null
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to test provider credential', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'credential_id' => $credential->id
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Test failed: ' . $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test provider API key validity
|
||||||
|
*/
|
||||||
|
private function testProviderApiKey(string $provider, string $apiKey): array
|
||||||
|
{
|
||||||
|
switch ($provider) {
|
||||||
|
case 'openai':
|
||||||
|
return $this->testOpenAI($apiKey);
|
||||||
|
|
||||||
|
case 'anthropic':
|
||||||
|
return $this->testAnthropic($apiKey);
|
||||||
|
|
||||||
|
case 'mistral':
|
||||||
|
return $this->testMistral($apiKey);
|
||||||
|
|
||||||
|
case 'gemini':
|
||||||
|
return $this->testGemini($apiKey);
|
||||||
|
|
||||||
|
case 'deepseek':
|
||||||
|
return $this->testDeepSeek($apiKey);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Unsupported provider'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testOpenAI(string $apiKey): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $apiKey,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
])->timeout(10)->post('https://api.openai.com/v1/chat/completions', [
|
||||||
|
'model' => 'gpt-3.5-turbo',
|
||||||
|
'messages' => [
|
||||||
|
['role' => 'user', 'content' => 'test']
|
||||||
|
],
|
||||||
|
'max_tokens' => 5
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'details' => 'Model: gpt-3.5-turbo accessible'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Invalid API key or insufficient permissions',
|
||||||
|
'error' => $response->body()
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Connection failed',
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testAnthropic(string $apiKey): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'x-api-key' => $apiKey,
|
||||||
|
'anthropic-version' => '2023-06-01',
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
])->timeout(10)->post('https://api.anthropic.com/v1/messages', [
|
||||||
|
'model' => 'claude-3-haiku-20240307',
|
||||||
|
'max_tokens' => 10,
|
||||||
|
'messages' => [
|
||||||
|
['role' => 'user', 'content' => 'test']
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'details' => 'Model: Claude 3 Haiku accessible'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Invalid API key or insufficient permissions',
|
||||||
|
'error' => $response->body()
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Connection failed',
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testMistral(string $apiKey): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $apiKey,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
])->timeout(10)->post('https://api.mistral.ai/v1/chat/completions', [
|
||||||
|
'model' => 'mistral-tiny',
|
||||||
|
'messages' => [
|
||||||
|
['role' => 'user', 'content' => 'test']
|
||||||
|
],
|
||||||
|
'max_tokens' => 5
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'details' => 'Model: Mistral Tiny accessible'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Invalid API key or insufficient permissions',
|
||||||
|
'error' => $response->body()
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Connection failed',
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testGemini(string $apiKey): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = Http::timeout(10)
|
||||||
|
->post("https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key={$apiKey}", [
|
||||||
|
'contents' => [
|
||||||
|
['parts' => [['text' => 'test']]]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'details' => 'Model: Gemini Pro accessible'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Invalid API key or insufficient permissions',
|
||||||
|
'error' => $response->body()
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Connection failed',
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testDeepSeek(string $apiKey): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $apiKey,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
])->timeout(10)->post('https://api.deepseek.com/v1/chat/completions', [
|
||||||
|
'model' => 'deepseek-chat',
|
||||||
|
'messages' => [
|
||||||
|
['role' => 'user', 'content' => 'test']
|
||||||
|
],
|
||||||
|
'max_tokens' => 5
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'details' => 'Model: DeepSeek Chat accessible'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Invalid API key or insufficient permissions',
|
||||||
|
'error' => $response->body()
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Connection failed',
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserBudget;
|
||||||
|
use App\Services\Budget\BudgetChecker;
|
||||||
|
use App\Services\RateLimit\RateLimitChecker;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class UserBudgetController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private BudgetChecker $budgetChecker,
|
||||||
|
private RateLimitChecker $rateLimitChecker
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display budget and rate limit status for a user
|
||||||
|
*/
|
||||||
|
public function show(User $user)
|
||||||
|
{
|
||||||
|
$budgetStatus = $this->budgetChecker->getBudgetStatus($user);
|
||||||
|
$rateLimitStatus = $this->rateLimitChecker->getRateLimitStatus($user);
|
||||||
|
|
||||||
|
return view('admin.user-budget.show', compact('user', 'budgetStatus', 'rateLimitStatus'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update budget limits for a user
|
||||||
|
*/
|
||||||
|
public function updateBudget(Request $request, User $user)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'monthly_limit' => 'required|numeric|min:0',
|
||||||
|
'daily_limit' => 'nullable|numeric|min:0',
|
||||||
|
'alert_threshold_percentage' => 'required|integer|min:0|max:100',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$budget = $user->budget ?? new UserBudget(['user_id' => $user->id]);
|
||||||
|
$budget->fill($validated);
|
||||||
|
$budget->save();
|
||||||
|
|
||||||
|
return back()->with('success', 'Budget limits updated successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update rate limits for a user
|
||||||
|
*/
|
||||||
|
public function updateRateLimit(Request $request, User $user)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'requests_per_minute' => 'required|integer|min:0',
|
||||||
|
'requests_per_hour' => 'required|integer|min:0',
|
||||||
|
'requests_per_day' => 'required|integer|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rateLimit = $user->rateLimit ?? new \App\Models\RateLimit(['user_id' => $user->id]);
|
||||||
|
$rateLimit->fill($validated);
|
||||||
|
$rateLimit->save();
|
||||||
|
|
||||||
|
return back()->with('success', 'Rate limits updated successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset rate limit for a user
|
||||||
|
*/
|
||||||
|
public function resetRateLimit(User $user)
|
||||||
|
{
|
||||||
|
$this->rateLimitChecker->resetRateLimit($user);
|
||||||
|
|
||||||
|
return back()->with('success', 'Rate limit reset successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset budget for a user (admin action)
|
||||||
|
*/
|
||||||
|
public function resetBudget(User $user)
|
||||||
|
{
|
||||||
|
$budget = $user->budget;
|
||||||
|
|
||||||
|
if ($budget) {
|
||||||
|
$budget->current_month_spending = 0.0;
|
||||||
|
$budget->current_day_spending = 0.0;
|
||||||
|
$budget->is_budget_exceeded = false;
|
||||||
|
$budget->last_alert_sent_at = null;
|
||||||
|
$budget->month_started_at = now()->startOfMonth();
|
||||||
|
$budget->day_started_at = now()->startOfDay();
|
||||||
|
$budget->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Budget reset successfully!');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class UserManagementController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of users with budget info
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = User::with(['budget', 'rateLimit', 'llmRequests'])
|
||||||
|
->withCount('llmRequests');
|
||||||
|
|
||||||
|
// Search
|
||||||
|
if ($request->has('search') && $request->search) {
|
||||||
|
$query->where(function ($q) use ($request) {
|
||||||
|
$q->where('name', 'like', '%' . $request->search . '%')
|
||||||
|
->orWhere('email', 'like', '%' . $request->search . '%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
$sortBy = $request->get('sort_by', 'created_at');
|
||||||
|
$sortOrder = $request->get('sort_order', 'desc');
|
||||||
|
$query->orderBy($sortBy, $sortOrder);
|
||||||
|
|
||||||
|
$users = $query->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return view('admin.users.index', compact('users'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\ChatCompletionRequest;
|
||||||
|
use App\Services\LLM\GatewayService;
|
||||||
|
use App\Exceptions\{ProviderException, InsufficientBudgetException, RateLimitExceededException};
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ChatCompletionController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private GatewayService $gatewayService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle chat completion request
|
||||||
|
*
|
||||||
|
* @param ChatCompletionRequest $request
|
||||||
|
* @return JsonResponse
|
||||||
|
*/
|
||||||
|
public function create(ChatCompletionRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$result = $this->gatewayService->chatCompletion(
|
||||||
|
user: $user,
|
||||||
|
provider: $request->input('provider'),
|
||||||
|
model: $request->input('model'),
|
||||||
|
messages: $request->input('messages'),
|
||||||
|
options: $request->only(['temperature', 'max_tokens', 'top_p', 'frequency_penalty', 'presence_penalty', 'stop']),
|
||||||
|
ipAddress: $request->ip(),
|
||||||
|
userAgent: $request->userAgent()
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($result, 200);
|
||||||
|
|
||||||
|
} catch (InsufficientBudgetException $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'budget_exceeded',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], 402); // Payment Required
|
||||||
|
|
||||||
|
} catch (RateLimitExceededException $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'rate_limit_exceeded',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
'retry_after' => $e->getRetryAfter(),
|
||||||
|
], 429);
|
||||||
|
|
||||||
|
} catch (ProviderException $e) {
|
||||||
|
Log::error('Provider error in chat completion', [
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
'provider' => $request->input('provider'),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'provider_error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], $e->getCode() ?: 500);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Unexpected error in chat completion', [
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'internal_error',
|
||||||
|
'message' => 'An unexpected error occurred. Please try again.',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,52 +70,39 @@ class ApiKeyController extends Controller
|
|||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'key_name' => 'required|string|max:255',
|
'key_name' => 'required|string|max:255',
|
||||||
'user_id' => 'required|string|exists:users,user_id',
|
'user_id' => 'required|string|exists:gateway_users,user_id',
|
||||||
'expires_at' => 'nullable|date|after:now',
|
'expires_at' => 'nullable|date|after:now',
|
||||||
'metadata' => 'nullable|json',
|
'metadata' => 'nullable|json',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get master key from config
|
// Generate a unique API token
|
||||||
$masterKey = env('GATEWAY_MASTER_KEY');
|
$token = 'llmg_' . Str::random(48);
|
||||||
if (!$masterKey) {
|
|
||||||
return back()->with('error', 'Gateway Master Key not configured');
|
// Parse metadata if provided
|
||||||
|
$metadata = null;
|
||||||
|
if (!empty($validated['metadata'])) {
|
||||||
|
$metadata = json_decode($validated['metadata'], true);
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
return back()->with('error', 'Invalid JSON in metadata field');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare request payload
|
// Create API key directly in database
|
||||||
$payload = [
|
$apiKey = ApiKey::create([
|
||||||
|
'token' => $token,
|
||||||
'user_id' => $validated['user_id'],
|
'user_id' => $validated['user_id'],
|
||||||
'key_name' => $validated['key_name'],
|
'key_name' => $validated['key_name'],
|
||||||
];
|
'key_alias' => $validated['key_name'], // Use key_name as alias
|
||||||
|
'expires' => $validated['expires_at'] ?? null,
|
||||||
// Add optional fields only if they have values
|
'metadata' => $metadata,
|
||||||
if (!empty($validated['expires_at'])) {
|
'permissions' => [], // Default empty permissions
|
||||||
$payload['expires_at'] = $validated['expires_at'];
|
'models' => [], // Default empty models
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($validated['metadata'])) {
|
|
||||||
$payload['metadata'] = json_decode($validated['metadata'], true) ?: new \stdClass();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Virtual Key via Any-LLM Gateway API
|
|
||||||
$response = Http::withHeaders([
|
|
||||||
'X-AnyLLM-Key' => 'Bearer ' . $masterKey,
|
|
||||||
'Content-Type' => 'application/json',
|
|
||||||
])->post(env('GATEWAY_API_URL', 'http://gateway:8000') . '/v1/keys', $payload);
|
|
||||||
|
|
||||||
if (!$response->successful()) {
|
|
||||||
Log::error('Failed to create API key', [
|
|
||||||
'status' => $response->status(),
|
|
||||||
'body' => $response->body()
|
|
||||||
]);
|
]);
|
||||||
return back()->with('error', 'Failed to create API key: ' . $response->body());
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $response->json();
|
// Store the token in session for one-time display
|
||||||
|
session()->flash('new_api_key', $token);
|
||||||
// The actual key is only available once - store it in session for display
|
session()->flash('new_api_key_id', $apiKey->token);
|
||||||
session()->flash('new_api_key', $data['key'] ?? null);
|
|
||||||
session()->flash('new_api_key_id', $data['id'] ?? null);
|
|
||||||
|
|
||||||
return redirect()->route('api-keys.index')
|
return redirect()->route('api-keys.index')
|
||||||
->with('success', 'API Key created successfully! Make sure to copy it now - it won\'t be shown again.');
|
->with('success', 'API Key created successfully! Make sure to copy it now - it won\'t be shown again.');
|
||||||
@@ -160,26 +147,8 @@ class ApiKeyController extends Controller
|
|||||||
try {
|
try {
|
||||||
$apiKey = ApiKey::findOrFail($id);
|
$apiKey = ApiKey::findOrFail($id);
|
||||||
|
|
||||||
// Get master key from config
|
// Delete the API key from database
|
||||||
$masterKey = env('GATEWAY_MASTER_KEY');
|
$apiKey->delete();
|
||||||
if (!$masterKey) {
|
|
||||||
return back()->with('error', 'Gateway Master Key not configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revoke via Any-LLM Gateway API
|
|
||||||
$response = Http::withHeaders([
|
|
||||||
'X-AnyLLM-Key' => 'Bearer ' . $masterKey,
|
|
||||||
'Content-Type' => 'application/json',
|
|
||||||
])->delete(env('GATEWAY_API_URL', 'http://gateway:8000') . '/v1/keys/' . $id);
|
|
||||||
|
|
||||||
if (!$response->successful()) {
|
|
||||||
Log::error('Failed to revoke API key', [
|
|
||||||
'key_id' => $id,
|
|
||||||
'status' => $response->status(),
|
|
||||||
'body' => $response->body()
|
|
||||||
]);
|
|
||||||
return back()->with('error', 'Failed to revoke API key: ' . $response->body());
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->route('api-keys.index')
|
return redirect()->route('api-keys.index')
|
||||||
->with('success', 'API Key revoked successfully');
|
->with('success', 'API Key revoked successfully');
|
||||||
|
|||||||
@@ -41,19 +41,35 @@ class BudgetController extends Controller
|
|||||||
'custom_duration_days' => 'nullable|integer|min:1|required_if:budget_type,custom',
|
'custom_duration_days' => 'nullable|integer|min:1|required_if:budget_type,custom',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Calculate budget_duration_sec based on type
|
// Set monthly and daily limits based on budget type
|
||||||
$duration = match($validated['budget_type']) {
|
$monthlyLimit = null;
|
||||||
'daily' => 86400, // 1 day
|
$dailyLimit = null;
|
||||||
'weekly' => 604800, // 7 days
|
|
||||||
'monthly' => 2592000, // 30 days
|
switch($validated['budget_type']) {
|
||||||
'custom' => ($validated['custom_duration_days'] ?? 1) * 86400,
|
case 'daily':
|
||||||
'unlimited' => null,
|
$dailyLimit = $validated['max_budget'];
|
||||||
};
|
break;
|
||||||
|
case 'weekly':
|
||||||
|
$dailyLimit = $validated['max_budget'] / 7;
|
||||||
|
break;
|
||||||
|
case 'monthly':
|
||||||
|
$monthlyLimit = $validated['max_budget'];
|
||||||
|
$dailyLimit = $validated['max_budget'] / 30;
|
||||||
|
break;
|
||||||
|
case 'custom':
|
||||||
|
$days = $validated['custom_duration_days'] ?? 1;
|
||||||
|
$dailyLimit = $validated['max_budget'] / $days;
|
||||||
|
break;
|
||||||
|
case 'unlimited':
|
||||||
|
// No limits
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
$budget = Budget::create([
|
$budget = Budget::create([
|
||||||
'budget_id' => 'budget-' . Str::uuid(),
|
'budget_id' => 'budget-' . Str::uuid(),
|
||||||
'max_budget' => $validated['max_budget'],
|
'name' => $validated['budget_name'],
|
||||||
'budget_duration_sec' => $duration,
|
'monthly_limit' => $monthlyLimit,
|
||||||
|
'daily_limit' => $dailyLimit,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
@@ -106,23 +122,40 @@ class BudgetController extends Controller
|
|||||||
$budget = Budget::findOrFail($id);
|
$budget = Budget::findOrFail($id);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
|
'budget_name' => 'required|string|max:255',
|
||||||
'max_budget' => 'required|numeric|min:0',
|
'max_budget' => 'required|numeric|min:0',
|
||||||
'budget_type' => 'required|in:daily,weekly,monthly,custom,unlimited',
|
'budget_type' => 'required|in:daily,weekly,monthly,custom,unlimited',
|
||||||
'custom_duration_days' => 'nullable|integer|min:1|required_if:budget_type,custom',
|
'custom_duration_days' => 'nullable|integer|min:1|required_if:budget_type,custom',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Calculate budget_duration_sec based on type
|
// Set monthly and daily limits based on budget type
|
||||||
$duration = match($validated['budget_type']) {
|
$monthlyLimit = null;
|
||||||
'daily' => 86400,
|
$dailyLimit = null;
|
||||||
'weekly' => 604800,
|
|
||||||
'monthly' => 2592000,
|
switch($validated['budget_type']) {
|
||||||
'custom' => ($validated['custom_duration_days'] ?? 1) * 86400,
|
case 'daily':
|
||||||
'unlimited' => null,
|
$dailyLimit = $validated['max_budget'];
|
||||||
};
|
break;
|
||||||
|
case 'weekly':
|
||||||
|
$dailyLimit = $validated['max_budget'] / 7;
|
||||||
|
break;
|
||||||
|
case 'monthly':
|
||||||
|
$monthlyLimit = $validated['max_budget'];
|
||||||
|
$dailyLimit = $validated['max_budget'] / 30;
|
||||||
|
break;
|
||||||
|
case 'custom':
|
||||||
|
$days = $validated['custom_duration_days'] ?? 1;
|
||||||
|
$dailyLimit = $validated['max_budget'] / $days;
|
||||||
|
break;
|
||||||
|
case 'unlimited':
|
||||||
|
// No limits
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
$budget->update([
|
$budget->update([
|
||||||
'max_budget' => $validated['max_budget'],
|
'name' => $validated['budget_name'],
|
||||||
'budget_duration_sec' => $duration,
|
'monthly_limit' => $monthlyLimit,
|
||||||
|
'daily_limit' => $dailyLimit,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
|
|||||||
@@ -21,13 +21,28 @@ class DashboardController extends Controller
|
|||||||
$topUsers = $this->statsService->getTopUsers(5);
|
$topUsers = $this->statsService->getTopUsers(5);
|
||||||
$providerStats = $this->statsService->getUsageByProvider(30);
|
$providerStats = $this->statsService->getUsageByProvider(30);
|
||||||
$modelStats = $this->statsService->getUsageByModel(30);
|
$modelStats = $this->statsService->getUsageByModel(30);
|
||||||
|
$costTrends = $this->statsService->getCostTrends(30);
|
||||||
|
$errorStats = $this->statsService->getErrorStats(30);
|
||||||
|
|
||||||
return view('dashboard', compact(
|
return view('dashboard', compact(
|
||||||
'stats',
|
'stats',
|
||||||
'dailyUsage',
|
'dailyUsage',
|
||||||
'topUsers',
|
'topUsers',
|
||||||
'providerStats',
|
'providerStats',
|
||||||
'modelStats'
|
'modelStats',
|
||||||
|
'costTrends',
|
||||||
|
'errorStats'
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get real-time stats via AJAX
|
||||||
|
*/
|
||||||
|
public function realtimeStats()
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'stats' => $this->statsService->getDashboardStats(),
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ class ModelPricingController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$modelPricing = ModelPricing::orderBy('model_key')
|
$modelPricing = ModelPricing::orderBy('provider')
|
||||||
|
->orderBy('model')
|
||||||
->paginate(20);
|
->paginate(20);
|
||||||
|
|
||||||
return view('model-pricing.index', compact('modelPricing'));
|
return view('model-pricing.index', compact('modelPricing'));
|
||||||
@@ -33,9 +34,16 @@ class ModelPricingController extends Controller
|
|||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'model_key' => 'required|string|max:255|unique:model_pricing,model_key',
|
'provider' => 'required|string|max:50',
|
||||||
|
'model' => 'required|string|max:100',
|
||||||
'input_price_per_million' => 'required|numeric|min:0',
|
'input_price_per_million' => 'required|numeric|min:0',
|
||||||
'output_price_per_million' => 'required|numeric|min:0',
|
'output_price_per_million' => 'required|numeric|min:0',
|
||||||
|
'context_window' => 'nullable|integer|min:0',
|
||||||
|
'max_output_tokens' => 'nullable|integer|min:0',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'effective_from' => 'nullable|date',
|
||||||
|
'effective_until' => 'nullable|date',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ModelPricing::create($validated);
|
ModelPricing::create($validated);
|
||||||
@@ -48,36 +56,38 @@ class ModelPricingController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display the specified model pricing
|
* Display the specified model pricing
|
||||||
*/
|
*/
|
||||||
public function show(string $modelKey)
|
public function show(ModelPricing $modelPricing)
|
||||||
{
|
{
|
||||||
$model = ModelPricing::findOrFail($modelKey);
|
return view('model-pricing.show', compact('modelPricing'));
|
||||||
|
|
||||||
return view('model-pricing.show', compact('model'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the form for editing the specified model pricing
|
* Show the form for editing the specified model pricing
|
||||||
*/
|
*/
|
||||||
public function edit(string $modelKey)
|
public function edit(ModelPricing $modelPricing)
|
||||||
{
|
{
|
||||||
$model = ModelPricing::findOrFail($modelKey);
|
return view('model-pricing.edit', compact('modelPricing'));
|
||||||
|
|
||||||
return view('model-pricing.edit', compact('model'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the specified model pricing
|
* Update the specified model pricing
|
||||||
*/
|
*/
|
||||||
public function update(Request $request, string $modelKey)
|
public function update(Request $request, ModelPricing $modelPricing)
|
||||||
{
|
{
|
||||||
$model = ModelPricing::findOrFail($modelKey);
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
|
'provider' => 'required|string|max:50',
|
||||||
|
'model' => 'required|string|max:100',
|
||||||
'input_price_per_million' => 'required|numeric|min:0',
|
'input_price_per_million' => 'required|numeric|min:0',
|
||||||
'output_price_per_million' => 'required|numeric|min:0',
|
'output_price_per_million' => 'required|numeric|min:0',
|
||||||
|
'context_window' => 'nullable|integer|min:0',
|
||||||
|
'max_output_tokens' => 'nullable|integer|min:0',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'effective_from' => 'nullable|date',
|
||||||
|
'effective_until' => 'nullable|date',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$model->update($validated);
|
$modelPricing->update($validated);
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('model-pricing.index')
|
->route('model-pricing.index')
|
||||||
@@ -87,10 +97,9 @@ class ModelPricingController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Remove the specified model pricing
|
* Remove the specified model pricing
|
||||||
*/
|
*/
|
||||||
public function destroy(string $modelKey)
|
public function destroy(ModelPricing $modelPricing)
|
||||||
{
|
{
|
||||||
$model = ModelPricing::findOrFail($modelKey);
|
$modelPricing->delete();
|
||||||
$model->delete();
|
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('model-pricing.index')
|
->route('model-pricing.index')
|
||||||
@@ -102,7 +111,10 @@ class ModelPricingController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function calculator()
|
public function calculator()
|
||||||
{
|
{
|
||||||
$models = ModelPricing::orderBy('model_key')->get();
|
$models = ModelPricing::where('is_active', true)
|
||||||
|
->orderBy('provider')
|
||||||
|
->orderBy('model')
|
||||||
|
->get();
|
||||||
|
|
||||||
return view('model-pricing.calculator', compact('models'));
|
return view('model-pricing.calculator', compact('models'));
|
||||||
}
|
}
|
||||||
@@ -113,19 +125,20 @@ class ModelPricingController extends Controller
|
|||||||
public function calculate(Request $request)
|
public function calculate(Request $request)
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'model_key' => 'required|exists:model_pricing,model_key',
|
'model_pricing_id' => 'required|exists:model_pricing,id',
|
||||||
'input_tokens' => 'required|integer|min:0',
|
'input_tokens' => 'required|integer|min:0',
|
||||||
'output_tokens' => 'required|integer|min:0',
|
'output_tokens' => 'required|integer|min:0',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$model = ModelPricing::findOrFail($validated['model_key']);
|
$model = ModelPricing::findOrFail($validated['model_pricing_id']);
|
||||||
$cost = $model->calculateCost(
|
$cost = $model->calculateCost(
|
||||||
$validated['input_tokens'],
|
$validated['input_tokens'],
|
||||||
$validated['output_tokens']
|
$validated['output_tokens']
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'model' => $model->model_key,
|
'provider' => $model->provider,
|
||||||
|
'model' => $model->model,
|
||||||
'input_tokens' => $validated['input_tokens'],
|
'input_tokens' => $validated['input_tokens'],
|
||||||
'output_tokens' => $validated['output_tokens'],
|
'output_tokens' => $validated['output_tokens'],
|
||||||
'total_tokens' => $validated['input_tokens'] + $validated['output_tokens'],
|
'total_tokens' => $validated['input_tokens'] + $validated['output_tokens'],
|
||||||
@@ -162,20 +175,23 @@ class ModelPricingController extends Controller
|
|||||||
fgetcsv($handle);
|
fgetcsv($handle);
|
||||||
|
|
||||||
while (($row = fgetcsv($handle)) !== false) {
|
while (($row = fgetcsv($handle)) !== false) {
|
||||||
if (count($row) < 3) {
|
if (count($row) < 4) {
|
||||||
continue; // Skip invalid rows
|
continue; // Skip invalid rows
|
||||||
}
|
}
|
||||||
|
|
||||||
$modelKey = trim($row[0]);
|
$provider = trim($row[0]);
|
||||||
$inputPrice = floatval($row[1]);
|
$model = trim($row[1]);
|
||||||
$outputPrice = floatval($row[2]);
|
$inputPrice = floatval($row[2]);
|
||||||
|
$outputPrice = floatval($row[3]);
|
||||||
|
|
||||||
if (empty($modelKey) || $inputPrice < 0 || $outputPrice < 0) {
|
if (empty($provider) || empty($model) || $inputPrice < 0 || $outputPrice < 0) {
|
||||||
$errors[] = "Invalid data for model: {$modelKey}";
|
$errors[] = "Invalid data for model: {$provider}/{$model}";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$existing = ModelPricing::find($modelKey);
|
$existing = ModelPricing::where('provider', $provider)
|
||||||
|
->where('model', $model)
|
||||||
|
->first();
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
$existing->update([
|
$existing->update([
|
||||||
@@ -185,7 +201,8 @@ class ModelPricingController extends Controller
|
|||||||
$updated++;
|
$updated++;
|
||||||
} else {
|
} else {
|
||||||
ModelPricing::create([
|
ModelPricing::create([
|
||||||
'model_key' => $modelKey,
|
'provider' => $provider,
|
||||||
|
'model' => $model,
|
||||||
'input_price_per_million' => $inputPrice,
|
'input_price_per_million' => $inputPrice,
|
||||||
'output_price_per_million' => $outputPrice,
|
'output_price_per_million' => $outputPrice,
|
||||||
]);
|
]);
|
||||||
@@ -205,4 +222,291 @@ class ModelPricingController extends Controller
|
|||||||
->route('model-pricing.index')
|
->route('model-pricing.index')
|
||||||
->with('success', $message);
|
->with('success', $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available models from a provider
|
||||||
|
*/
|
||||||
|
public function getProviderModels(string $provider)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$models = [];
|
||||||
|
|
||||||
|
switch($provider) {
|
||||||
|
case 'openai':
|
||||||
|
$models = $this->getOpenAIModels();
|
||||||
|
break;
|
||||||
|
case 'anthropic':
|
||||||
|
$models = $this->getAnthropicModels();
|
||||||
|
break;
|
||||||
|
case 'deepseek':
|
||||||
|
$models = $this->getDeepSeekModels();
|
||||||
|
break;
|
||||||
|
case 'google':
|
||||||
|
$models = $this->getGeminiModels();
|
||||||
|
break;
|
||||||
|
case 'cohere':
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Cohere does not provide a public models API. Please enter model names manually'
|
||||||
|
]);
|
||||||
|
case 'mistral':
|
||||||
|
$models = $this->getMistralModels();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Provider not supported for automatic model fetching'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'models' => $models
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch OpenAI models
|
||||||
|
*/
|
||||||
|
private function getOpenAIModels()
|
||||||
|
{
|
||||||
|
// Get API key from credentials
|
||||||
|
$credential = \App\Models\UserProviderCredential::where('provider', 'openai')
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$credential || !$credential->api_key) {
|
||||||
|
throw new \Exception('No active OpenAI credentials found. Please add credentials first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = \Illuminate\Support\Facades\Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $credential->api_key,
|
||||||
|
])->get('https://api.openai.com/v1/models');
|
||||||
|
|
||||||
|
if (!$response->successful()) {
|
||||||
|
throw new \Exception('Failed to fetch OpenAI models: ' . $response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
$models = [];
|
||||||
|
|
||||||
|
foreach ($data['data'] as $model) {
|
||||||
|
if (isset($model['id'])) {
|
||||||
|
$models[] = [
|
||||||
|
'id' => $model['id'],
|
||||||
|
'name' => $model['id'],
|
||||||
|
'created' => $model['created'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by name
|
||||||
|
usort($models, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||||
|
|
||||||
|
return $models;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Anthropic models from API
|
||||||
|
*/
|
||||||
|
private function getAnthropicModels()
|
||||||
|
{
|
||||||
|
// Get API key from credentials
|
||||||
|
$credential = \App\Models\UserProviderCredential::where('provider', 'anthropic')
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$credential || !$credential->api_key) {
|
||||||
|
throw new \Exception('No active Anthropic credentials found. Please add credentials first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = \Illuminate\Support\Facades\Http::withHeaders([
|
||||||
|
'x-api-key' => $credential->api_key,
|
||||||
|
'anthropic-version' => '2023-06-01',
|
||||||
|
])->get('https://api.anthropic.com/v1/models');
|
||||||
|
|
||||||
|
if (!$response->successful()) {
|
||||||
|
throw new \Exception('Failed to fetch Anthropic models: ' . $response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
$models = [];
|
||||||
|
|
||||||
|
foreach ($data['data'] as $model) {
|
||||||
|
if (isset($model['id'])) {
|
||||||
|
$models[] = [
|
||||||
|
'id' => $model['id'],
|
||||||
|
'name' => $model['display_name'] ?? $model['id'],
|
||||||
|
'created' => isset($model['created_at']) ? strtotime($model['created_at']) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by name
|
||||||
|
usort($models, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||||
|
|
||||||
|
return $models;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch DeepSeek models from API
|
||||||
|
*/
|
||||||
|
private function getDeepSeekModels()
|
||||||
|
{
|
||||||
|
// Get API key from credentials
|
||||||
|
$credential = \App\Models\UserProviderCredential::where('provider', 'deepseek')
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$credential || !$credential->api_key) {
|
||||||
|
throw new \Exception('No active DeepSeek credentials found. Please add credentials first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepSeek uses OpenAI-compatible API
|
||||||
|
$response = \Illuminate\Support\Facades\Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $credential->api_key,
|
||||||
|
])->get('https://api.deepseek.com/models');
|
||||||
|
|
||||||
|
if (!$response->successful()) {
|
||||||
|
throw new \Exception('Failed to fetch DeepSeek models: ' . $response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
$models = [];
|
||||||
|
|
||||||
|
foreach ($data['data'] as $model) {
|
||||||
|
if (isset($model['id'])) {
|
||||||
|
$models[] = [
|
||||||
|
'id' => $model['id'],
|
||||||
|
'name' => $model['id'],
|
||||||
|
'created' => $model['created'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by name
|
||||||
|
usort($models, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||||
|
|
||||||
|
return $models;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Google Gemini models from API
|
||||||
|
*/
|
||||||
|
private function getGeminiModels()
|
||||||
|
{
|
||||||
|
// Get API key from credentials
|
||||||
|
$credential = \App\Models\UserProviderCredential::where('provider', 'gemini')
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$credential || !$credential->api_key) {
|
||||||
|
throw new \Exception('No active Google Gemini credentials found. Please add credentials first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$models = [];
|
||||||
|
$nextPageToken = null;
|
||||||
|
|
||||||
|
// Fetch all pages
|
||||||
|
do {
|
||||||
|
$url = 'https://generativelanguage.googleapis.com/v1beta/models?key=' . $credential->api_key;
|
||||||
|
if ($nextPageToken) {
|
||||||
|
$url .= '&pageToken=' . $nextPageToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = \Illuminate\Support\Facades\Http::get($url);
|
||||||
|
|
||||||
|
if (!$response->successful()) {
|
||||||
|
throw new \Exception('Failed to fetch Gemini models: ' . $response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
foreach ($data['models'] as $model) {
|
||||||
|
// Only include models that support generateContent
|
||||||
|
if (isset($model['name']) &&
|
||||||
|
isset($model['supportedGenerationMethods']) &&
|
||||||
|
in_array('generateContent', $model['supportedGenerationMethods'])) {
|
||||||
|
|
||||||
|
// Extract model ID from "models/gemini-xxx" format
|
||||||
|
$modelId = str_replace('models/', '', $model['name']);
|
||||||
|
|
||||||
|
$models[] = [
|
||||||
|
'id' => $modelId,
|
||||||
|
'name' => $model['displayName'] ?? $modelId,
|
||||||
|
'created' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextPageToken = $data['nextPageToken'] ?? null;
|
||||||
|
|
||||||
|
} while ($nextPageToken);
|
||||||
|
|
||||||
|
// Sort by name
|
||||||
|
usort($models, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||||
|
|
||||||
|
return $models;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Mistral AI models from API
|
||||||
|
*/
|
||||||
|
private function getMistralModels()
|
||||||
|
{
|
||||||
|
// Get API key from credentials
|
||||||
|
$credential = \App\Models\UserProviderCredential::where('provider', 'mistral')
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$credential || !$credential->api_key) {
|
||||||
|
throw new \Exception('No active Mistral AI credentials found. Please add credentials first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = \Illuminate\Support\Facades\Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $credential->api_key,
|
||||||
|
])->get('https://api.mistral.ai/v1/models');
|
||||||
|
|
||||||
|
if (!$response->successful()) {
|
||||||
|
throw new \Exception('Failed to fetch Mistral models: ' . $response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
$models = [];
|
||||||
|
$seenIds = []; // Track seen IDs to avoid duplicates (aliases)
|
||||||
|
|
||||||
|
foreach ($data['data'] as $model) {
|
||||||
|
// Skip deprecated models
|
||||||
|
if (isset($model['deprecation']) && $model['deprecation']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only include models that support chat completion
|
||||||
|
if (isset($model['id']) &&
|
||||||
|
isset($model['capabilities']['completion_chat']) &&
|
||||||
|
$model['capabilities']['completion_chat'] === true &&
|
||||||
|
!isset($seenIds[$model['id']])) {
|
||||||
|
|
||||||
|
$seenIds[$model['id']] = true;
|
||||||
|
|
||||||
|
$models[] = [
|
||||||
|
'id' => $model['id'],
|
||||||
|
'name' => $model['name'] ?? $model['id'],
|
||||||
|
'created' => $model['created'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by name
|
||||||
|
usort($models, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||||
|
|
||||||
|
return $models;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
laravel-app/app/Http/Middleware/CheckBudget.php
Normal file
33
laravel-app/app/Http/Middleware/CheckBudget.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use App\Services\Budget\BudgetChecker;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class CheckBudget
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private BudgetChecker $budgetChecker
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
// Check budget before processing request
|
||||||
|
// Estimated cost is 0 for now, will be calculated after request
|
||||||
|
$this->budgetChecker->checkBudget($user, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
laravel-app/app/Http/Middleware/CheckRateLimit.php
Normal file
35
laravel-app/app/Http/Middleware/CheckRateLimit.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use App\Services\RateLimit\RateLimitChecker;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class CheckRateLimit
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private RateLimitChecker $rateLimitChecker
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
// Check rate limit before processing request
|
||||||
|
$this->rateLimitChecker->checkRateLimit($user);
|
||||||
|
|
||||||
|
// Increment counter after successful check
|
||||||
|
$this->rateLimitChecker->incrementCounter($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
laravel-app/app/Http/Requests/ChatCompletionRequest.php
Normal file
58
laravel-app/app/Http/Requests/ChatCompletionRequest.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use App\Services\LLM\ProviderFactory;
|
||||||
|
|
||||||
|
class ChatCompletionRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true; // Authorization handled by auth middleware
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'provider' => ['required', 'string', function ($attribute, $value, $fail) {
|
||||||
|
if (!ProviderFactory::isSupported($value)) {
|
||||||
|
$fail("The {$attribute} must be one of: " . implode(', ', ProviderFactory::getSupportedProviders()));
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
'model' => 'required|string|max:100',
|
||||||
|
'messages' => 'required|array|min:1',
|
||||||
|
'messages.*.role' => 'required|string|in:system,user,assistant',
|
||||||
|
'messages.*.content' => 'required|string',
|
||||||
|
|
||||||
|
// Optional parameters
|
||||||
|
'temperature' => 'sometimes|numeric|min:0|max:2',
|
||||||
|
'max_tokens' => 'sometimes|integer|min:1|max:100000',
|
||||||
|
'top_p' => 'sometimes|numeric|min:0|max:1',
|
||||||
|
'frequency_penalty' => 'sometimes|numeric|min:-2|max:2',
|
||||||
|
'presence_penalty' => 'sometimes|numeric|min:-2|max:2',
|
||||||
|
'stop' => 'sometimes|array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get custom messages for validator errors.
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'provider.required' => 'Provider is required (e.g., openai, anthropic)',
|
||||||
|
'model.required' => 'Model is required (e.g., gpt-4o-mini, claude-sonnet-4)',
|
||||||
|
'messages.required' => 'Messages array is required',
|
||||||
|
'messages.*.role.in' => 'Message role must be system, user, or assistant',
|
||||||
|
'temperature.between' => 'Temperature must be between 0 and 2',
|
||||||
|
'max_tokens.min' => 'Max tokens must be at least 1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
88
laravel-app/app/Jobs/LogLlmRequest.php
Normal file
88
laravel-app/app/Jobs/LogLlmRequest.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\LlmRequest;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class LogLlmRequest implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 30;
|
||||||
|
public int $tries = 3;
|
||||||
|
public int $maxExceptions = 3;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private int $userId,
|
||||||
|
private string $provider,
|
||||||
|
private string $model,
|
||||||
|
private array $requestPayload,
|
||||||
|
private ?array $responsePayload,
|
||||||
|
private int $promptTokens,
|
||||||
|
private int $completionTokens,
|
||||||
|
private int $totalTokens,
|
||||||
|
private ?int $responseTimeMs,
|
||||||
|
private float $promptCost,
|
||||||
|
private float $completionCost,
|
||||||
|
private float $totalCost,
|
||||||
|
private string $status,
|
||||||
|
private ?string $errorMessage = null,
|
||||||
|
private ?int $httpStatus = null,
|
||||||
|
private ?string $ipAddress = null,
|
||||||
|
private ?string $userAgent = null,
|
||||||
|
private ?string $requestId = null
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
LlmRequest::create([
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'provider' => $this->provider,
|
||||||
|
'model' => $this->model,
|
||||||
|
'request_payload' => $this->requestPayload,
|
||||||
|
'response_payload' => $this->responsePayload,
|
||||||
|
'prompt_tokens' => $this->promptTokens,
|
||||||
|
'completion_tokens' => $this->completionTokens,
|
||||||
|
'total_tokens' => $this->totalTokens,
|
||||||
|
'response_time_ms' => $this->responseTimeMs,
|
||||||
|
'prompt_cost' => $this->promptCost,
|
||||||
|
'completion_cost' => $this->completionCost,
|
||||||
|
'total_cost' => $this->totalCost,
|
||||||
|
'status' => $this->status,
|
||||||
|
'error_message' => $this->errorMessage,
|
||||||
|
'http_status' => $this->httpStatus,
|
||||||
|
'ip_address' => $this->ipAddress,
|
||||||
|
'user_agent' => $this->userAgent,
|
||||||
|
'request_id' => $this->requestId,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to log LLM request', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'provider' => $this->provider,
|
||||||
|
'model' => $this->model,
|
||||||
|
'request_id' => $this->requestId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function failed(\Throwable $exception): void
|
||||||
|
{
|
||||||
|
Log::critical('LogLlmRequest job failed after all retries', [
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'provider' => $this->provider,
|
||||||
|
'model' => $this->model,
|
||||||
|
'request_id' => $this->requestId,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
laravel-app/app/Jobs/ResetDailyBudgets.php
Normal file
45
laravel-app/app/Jobs/ResetDailyBudgets.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\UserBudget;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ResetDailyBudgets implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
$today = $now->startOfDay();
|
||||||
|
|
||||||
|
// Find all budgets that need daily reset
|
||||||
|
$budgets = UserBudget::where('day_started_at', '<', $today)
|
||||||
|
->where('is_active', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$resetCount = 0;
|
||||||
|
|
||||||
|
foreach ($budgets as $budget) {
|
||||||
|
$budget->current_day_spending = 0.0;
|
||||||
|
$budget->day_started_at = $today;
|
||||||
|
$budget->save();
|
||||||
|
|
||||||
|
$resetCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Daily budgets reset', [
|
||||||
|
'count' => $resetCount,
|
||||||
|
'date' => $today->toDateString()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
laravel-app/app/Jobs/ResetMonthlyBudgets.php
Normal file
52
laravel-app/app/Jobs/ResetMonthlyBudgets.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\UserBudget;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ResetMonthlyBudgets implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
$thisMonth = $now->startOfMonth();
|
||||||
|
|
||||||
|
// Find all budgets that need monthly reset
|
||||||
|
$budgets = UserBudget::where('month_started_at', '<', $thisMonth)
|
||||||
|
->where('is_active', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$resetCount = 0;
|
||||||
|
|
||||||
|
foreach ($budgets as $budget) {
|
||||||
|
$budget->current_month_spending = 0.0;
|
||||||
|
$budget->month_started_at = $thisMonth;
|
||||||
|
$budget->is_budget_exceeded = false;
|
||||||
|
$budget->last_alert_sent_at = null;
|
||||||
|
$budget->save();
|
||||||
|
|
||||||
|
$resetCount++;
|
||||||
|
|
||||||
|
Log::info('Monthly budget reset for user', [
|
||||||
|
'user_id' => $budget->user_id,
|
||||||
|
'previous_spending' => $budget->current_month_spending
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Monthly budgets reset', [
|
||||||
|
'count' => $resetCount,
|
||||||
|
'month' => $thisMonth->format('Y-m')
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
|
||||||
use Illuminate\Notifications\Notifiable;
|
|
||||||
|
|
||||||
class Admin extends Authenticatable
|
|
||||||
{
|
|
||||||
use Notifiable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The attributes that are mass assignable.
|
|
||||||
*
|
|
||||||
* @var array<int, string>
|
|
||||||
*/
|
|
||||||
protected $fillable = [
|
|
||||||
'name',
|
|
||||||
'email',
|
|
||||||
'password',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The attributes that should be hidden for serialization.
|
|
||||||
*
|
|
||||||
* @var array<int, string>
|
|
||||||
*/
|
|
||||||
protected $hidden = [
|
|
||||||
'password',
|
|
||||||
'remember_token',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the attributes that should be cast.
|
|
||||||
*
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'email_verified_at' => 'datetime',
|
|
||||||
'password' => 'hashed',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,34 +2,73 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class ApiKey extends Model
|
class ApiKey extends Model
|
||||||
{
|
{
|
||||||
protected $primaryKey = 'id';
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'api_keys';
|
||||||
|
protected $primaryKey = 'token';
|
||||||
public $incrementing = false;
|
public $incrementing = false;
|
||||||
protected $keyType = 'string';
|
protected $keyType = 'string';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'id',
|
'token',
|
||||||
'key_hash',
|
|
||||||
'key_name',
|
|
||||||
'user_id',
|
'user_id',
|
||||||
'last_used_at',
|
'key_alias',
|
||||||
'expires_at',
|
'key_name',
|
||||||
'is_active',
|
'permissions',
|
||||||
|
'models',
|
||||||
'metadata',
|
'metadata',
|
||||||
|
'expires',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected $casts = [
|
||||||
{
|
'permissions' => 'array',
|
||||||
return [
|
'models' => 'array',
|
||||||
'is_active' => 'boolean',
|
|
||||||
'metadata' => 'array',
|
'metadata' => 'array',
|
||||||
|
'expires' => 'datetime',
|
||||||
'created_at' => 'datetime',
|
'created_at' => 'datetime',
|
||||||
'last_used_at' => 'datetime',
|
'updated_at' => 'datetime',
|
||||||
'expires_at' => 'datetime',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get masked version of the key
|
||||||
|
*/
|
||||||
|
public function getMaskedKeyAttribute(): string
|
||||||
|
{
|
||||||
|
return substr($this->token, 0, 8) . '...' . substr($this->token, -4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if key is active (not explicitly marked inactive)
|
||||||
|
*/
|
||||||
|
public function getIsActiveAttribute(): bool
|
||||||
|
{
|
||||||
|
// For now, consider all keys active unless explicitly deleted
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if key is expired
|
||||||
|
*/
|
||||||
|
public function getIsExpiredAttribute(): bool
|
||||||
|
{
|
||||||
|
if (!$this->expires) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return $this->expires->isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get last used at timestamp
|
||||||
|
*/
|
||||||
|
public function getLastUsedAtAttribute()
|
||||||
|
{
|
||||||
|
$latestLog = $this->usageLogs()->latest('timestamp')->first();
|
||||||
|
return $latestLog ? $latestLog->timestamp : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function gatewayUser()
|
public function gatewayUser()
|
||||||
@@ -37,33 +76,14 @@ class ApiKey extends Model
|
|||||||
return $this->belongsTo(GatewayUser::class, 'user_id', 'user_id');
|
return $this->belongsTo(GatewayUser::class, 'user_id', 'user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alias for backwards compatibility
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->gatewayUser();
|
||||||
|
}
|
||||||
|
|
||||||
public function usageLogs()
|
public function usageLogs()
|
||||||
{
|
{
|
||||||
return $this->hasMany(UsageLog::class, 'api_key_id', 'id');
|
return $this->hasMany(UsageLog::class, 'api_key', 'token');
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeActive($query)
|
|
||||||
{
|
|
||||||
return $query->where('is_active', true)
|
|
||||||
->where(function ($q) {
|
|
||||||
$q->whereNull('expires_at')
|
|
||||||
->orWhere('expires_at', '>', now());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeExpired($query)
|
|
||||||
{
|
|
||||||
return $query->whereNotNull('expires_at')
|
|
||||||
->where('expires_at', '<=', now());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getMaskedKeyAttribute()
|
|
||||||
{
|
|
||||||
return 'gw-' . substr($this->id, 0, 8) . '...' . substr($this->id, -8);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getIsExpiredAttribute()
|
|
||||||
{
|
|
||||||
return $this->expires_at && $this->expires_at->isPast();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,47 +2,61 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class Budget extends Model
|
class Budget extends Model
|
||||||
{
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'budgets';
|
||||||
protected $primaryKey = 'budget_id';
|
protected $primaryKey = 'budget_id';
|
||||||
public $incrementing = false;
|
public $incrementing = false;
|
||||||
protected $keyType = 'string';
|
protected $keyType = 'string';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'budget_id',
|
'budget_id',
|
||||||
'max_budget',
|
'name',
|
||||||
'budget_duration_sec',
|
'monthly_limit',
|
||||||
|
'daily_limit',
|
||||||
|
'created_by',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected $casts = [
|
||||||
{
|
'monthly_limit' => 'decimal:2',
|
||||||
return [
|
'daily_limit' => 'decimal:2',
|
||||||
'max_budget' => 'double',
|
|
||||||
'budget_duration_sec' => 'integer',
|
|
||||||
'created_at' => 'datetime',
|
|
||||||
'updated_at' => 'datetime',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted max budget display
|
||||||
|
*/
|
||||||
|
public function getMaxBudgetFormattedAttribute(): string
|
||||||
|
{
|
||||||
|
if ($this->monthly_limit) {
|
||||||
|
return '$' . number_format($this->monthly_limit, 2);
|
||||||
|
}
|
||||||
|
if ($this->daily_limit) {
|
||||||
|
return '$' . number_format($this->daily_limit, 2) . '/day';
|
||||||
|
}
|
||||||
|
return 'Unlimited';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable duration
|
||||||
|
*/
|
||||||
|
public function getDurationHumanAttribute(): string
|
||||||
|
{
|
||||||
|
if ($this->monthly_limit && $this->daily_limit) {
|
||||||
|
return 'Monthly';
|
||||||
|
}
|
||||||
|
if ($this->daily_limit && !$this->monthly_limit) {
|
||||||
|
return 'Daily';
|
||||||
|
}
|
||||||
|
return 'Unlimited';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function gatewayUsers()
|
public function gatewayUsers()
|
||||||
{
|
{
|
||||||
return $this->hasMany(GatewayUser::class, 'budget_id', 'budget_id');
|
return $this->hasMany(GatewayUser::class, 'budget_id', 'budget_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getMaxBudgetFormattedAttribute()
|
|
||||||
{
|
|
||||||
return '$' . number_format($this->max_budget, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDurationHumanAttribute()
|
|
||||||
{
|
|
||||||
if (!$this->budget_duration_sec) return 'No limit';
|
|
||||||
|
|
||||||
$days = floor($this->budget_duration_sec / 86400);
|
|
||||||
$hours = floor(($this->budget_duration_sec % 86400) / 3600);
|
|
||||||
|
|
||||||
return "{$days}d {$hours}h";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,74 +2,45 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class GatewayUser extends Model
|
class GatewayUser extends Model
|
||||||
{
|
{
|
||||||
/**
|
use HasFactory;
|
||||||
* The table associated with the model.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $table = 'users';
|
|
||||||
|
|
||||||
/**
|
protected $table = 'gateway_users';
|
||||||
* The primary key for the model.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $primaryKey = 'user_id';
|
protected $primaryKey = 'user_id';
|
||||||
|
|
||||||
/**
|
|
||||||
* Indicates if the IDs are auto-incrementing.
|
|
||||||
*
|
|
||||||
* @var bool
|
|
||||||
*/
|
|
||||||
public $incrementing = false;
|
public $incrementing = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* The data type of the primary key ID.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $keyType = 'string';
|
protected $keyType = 'string';
|
||||||
|
|
||||||
/**
|
|
||||||
* The attributes that are mass assignable.
|
|
||||||
*
|
|
||||||
* @var array<int, string>
|
|
||||||
*/
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'user_id',
|
'user_id',
|
||||||
'alias',
|
'alias',
|
||||||
'spend',
|
|
||||||
'budget_id',
|
'budget_id',
|
||||||
|
'spend',
|
||||||
'blocked',
|
'blocked',
|
||||||
'metadata',
|
'metadata',
|
||||||
'budget_started_at',
|
];
|
||||||
'next_budget_reset_at',
|
|
||||||
|
protected $casts = [
|
||||||
|
'metadata' => 'array',
|
||||||
|
'blocked' => 'boolean',
|
||||||
|
'spend' => 'decimal:2',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'updated_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* Get the budget associated with the user.
|
||||||
*
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
*/
|
||||||
protected function casts(): array
|
public function budget()
|
||||||
{
|
{
|
||||||
return [
|
return $this->belongsTo(Budget::class, 'budget_id', 'budget_id');
|
||||||
'spend' => 'double',
|
|
||||||
'blocked' => 'boolean',
|
|
||||||
'metadata' => 'array',
|
|
||||||
'created_at' => 'datetime',
|
|
||||||
'updated_at' => 'datetime',
|
|
||||||
'budget_started_at' => 'datetime',
|
|
||||||
'next_budget_reset_at' => 'datetime',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the API keys for this user.
|
* Get the API keys for the user.
|
||||||
*/
|
*/
|
||||||
public function apiKeys()
|
public function apiKeys()
|
||||||
{
|
{
|
||||||
@@ -77,21 +48,13 @@ class GatewayUser extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the usage logs for this user.
|
* Get the usage logs for the user.
|
||||||
*/
|
*/
|
||||||
public function usageLogs()
|
public function usageLogs()
|
||||||
{
|
{
|
||||||
return $this->hasMany(UsageLog::class, 'user_id', 'user_id');
|
return $this->hasMany(UsageLog::class, 'user_id', 'user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the budget for this user.
|
|
||||||
*/
|
|
||||||
public function budget()
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Budget::class, 'budget_id', 'budget_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope a query to only include active users.
|
* Scope a query to only include active users.
|
||||||
*/
|
*/
|
||||||
@@ -107,28 +70,4 @@ class GatewayUser extends Model
|
|||||||
{
|
{
|
||||||
return $query->where('blocked', true);
|
return $query->where('blocked', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the formatted spend amount.
|
|
||||||
*/
|
|
||||||
public function getSpendFormattedAttribute()
|
|
||||||
{
|
|
||||||
return '$' . number_format($this->spend, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the total number of requests.
|
|
||||||
*/
|
|
||||||
public function getTotalRequestsAttribute()
|
|
||||||
{
|
|
||||||
return $this->usageLogs()->count();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the total number of tokens used.
|
|
||||||
*/
|
|
||||||
public function getTotalTokensAttribute()
|
|
||||||
{
|
|
||||||
return $this->usageLogs()->sum('total_tokens');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
58
laravel-app/app/Models/LlmRequest.php
Normal file
58
laravel-app/app/Models/LlmRequest.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class LlmRequest extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'provider',
|
||||||
|
'model',
|
||||||
|
'request_payload',
|
||||||
|
'response_payload',
|
||||||
|
'prompt_tokens',
|
||||||
|
'completion_tokens',
|
||||||
|
'total_tokens',
|
||||||
|
'response_time_ms',
|
||||||
|
'prompt_cost',
|
||||||
|
'completion_cost',
|
||||||
|
'total_cost',
|
||||||
|
'status',
|
||||||
|
'error_message',
|
||||||
|
'http_status',
|
||||||
|
'ip_address',
|
||||||
|
'user_agent',
|
||||||
|
'request_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'request_payload' => 'array',
|
||||||
|
'response_payload' => 'array',
|
||||||
|
'prompt_tokens' => 'integer',
|
||||||
|
'completion_tokens' => 'integer',
|
||||||
|
'total_tokens' => 'integer',
|
||||||
|
'response_time_ms' => 'integer',
|
||||||
|
'prompt_cost' => 'decimal:6',
|
||||||
|
'completion_cost' => 'decimal:6',
|
||||||
|
'total_cost' => 'decimal:6',
|
||||||
|
'http_status' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSuccess(): bool
|
||||||
|
{
|
||||||
|
return $this->status === 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isFailed(): bool
|
||||||
|
{
|
||||||
|
return $this->status === 'failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,45 +7,53 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
class ModelPricing extends Model
|
class ModelPricing extends Model
|
||||||
{
|
{
|
||||||
protected $table = 'model_pricing';
|
protected $table = 'model_pricing';
|
||||||
protected $primaryKey = 'model_key';
|
|
||||||
public $incrementing = false;
|
|
||||||
protected $keyType = 'string';
|
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'model_key',
|
'provider',
|
||||||
|
'model',
|
||||||
'input_price_per_million',
|
'input_price_per_million',
|
||||||
'output_price_per_million',
|
'output_price_per_million',
|
||||||
|
'context_window',
|
||||||
|
'max_output_tokens',
|
||||||
|
'is_active',
|
||||||
|
'effective_from',
|
||||||
|
'effective_until',
|
||||||
|
'notes',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected $casts = [
|
||||||
{
|
'input_price_per_million' => 'decimal:4',
|
||||||
return [
|
'output_price_per_million' => 'decimal:4',
|
||||||
'input_price_per_million' => 'double',
|
'context_window' => 'integer',
|
||||||
'output_price_per_million' => 'double',
|
'max_output_tokens' => 'integer',
|
||||||
'created_at' => 'datetime',
|
'is_active' => 'boolean',
|
||||||
'updated_at' => 'datetime',
|
'effective_from' => 'date',
|
||||||
|
'effective_until' => 'date',
|
||||||
];
|
];
|
||||||
}
|
|
||||||
|
|
||||||
// Accessors
|
public function getInputPriceFormattedAttribute(): string
|
||||||
public function getInputPriceFormattedAttribute()
|
|
||||||
{
|
{
|
||||||
return '$' . number_format($this->input_price_per_million, 2) . '/M';
|
return '$' . number_format($this->input_price_per_million, 2) . '/M';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getOutputPriceFormattedAttribute()
|
public function getOutputPriceFormattedAttribute(): string
|
||||||
{
|
{
|
||||||
return '$' . number_format($this->output_price_per_million, 2) . '/M';
|
return '$' . number_format($this->output_price_per_million, 2) . '/M';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function calculateCost(int $inputTokens, int $outputTokens): float
|
||||||
* Calculate cost for given token counts
|
|
||||||
*/
|
|
||||||
public function calculateCost($inputTokens, $outputTokens)
|
|
||||||
{
|
{
|
||||||
$inputCost = ($inputTokens / 1000000) * $this->input_price_per_million;
|
$inputCost = ($inputTokens / 1_000_000) * $this->input_price_per_million;
|
||||||
$outputCost = ($outputTokens / 1000000) * $this->output_price_per_million;
|
$outputCost = ($outputTokens / 1_000_000) * $this->output_price_per_million;
|
||||||
|
|
||||||
return $inputCost + $outputCost;
|
return round($inputCost + $outputCost, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCurrentlyActive(): bool
|
||||||
|
{
|
||||||
|
$now = now()->toDateString();
|
||||||
|
return $this->is_active
|
||||||
|
&& $this->effective_from <= $now
|
||||||
|
&& ($this->effective_until === null || $this->effective_until >= $now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
74
laravel-app/app/Models/RateLimit.php
Normal file
74
laravel-app/app/Models/RateLimit.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class RateLimit extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'requests_per_minute',
|
||||||
|
'requests_per_hour',
|
||||||
|
'requests_per_day',
|
||||||
|
'current_minute_count',
|
||||||
|
'current_hour_count',
|
||||||
|
'current_day_count',
|
||||||
|
'minute_started_at',
|
||||||
|
'hour_started_at',
|
||||||
|
'day_started_at',
|
||||||
|
'is_rate_limited',
|
||||||
|
'rate_limit_expires_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'requests_per_minute' => 'integer',
|
||||||
|
'requests_per_hour' => 'integer',
|
||||||
|
'requests_per_day' => 'integer',
|
||||||
|
'current_minute_count' => 'integer',
|
||||||
|
'current_hour_count' => 'integer',
|
||||||
|
'current_day_count' => 'integer',
|
||||||
|
'minute_started_at' => 'datetime',
|
||||||
|
'hour_started_at' => 'datetime',
|
||||||
|
'day_started_at' => 'datetime',
|
||||||
|
'is_rate_limited' => 'boolean',
|
||||||
|
'rate_limit_expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isMinuteLimitExceeded(): bool
|
||||||
|
{
|
||||||
|
if ($this->minute_started_at->lt(now()->subMinute())) {
|
||||||
|
return false; // Period expired, should be reset
|
||||||
|
}
|
||||||
|
return $this->current_minute_count >= $this->requests_per_minute;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isHourLimitExceeded(): bool
|
||||||
|
{
|
||||||
|
if ($this->hour_started_at->lt(now()->subHour())) {
|
||||||
|
return false; // Period expired, should be reset
|
||||||
|
}
|
||||||
|
return $this->current_hour_count >= $this->requests_per_hour;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDayLimitExceeded(): bool
|
||||||
|
{
|
||||||
|
if ($this->day_started_at->lt(now()->subDay())) {
|
||||||
|
return false; // Period expired, should be reset
|
||||||
|
}
|
||||||
|
return $this->current_day_count >= $this->requests_per_day;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAnyLimitExceeded(): bool
|
||||||
|
{
|
||||||
|
return $this->isMinuteLimitExceeded()
|
||||||
|
|| $this->isHourLimitExceeded()
|
||||||
|
|| $this->isDayLimitExceeded();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,20 +2,23 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class UsageLog extends Model
|
class UsageLog extends Model
|
||||||
{
|
{
|
||||||
protected $primaryKey = 'id';
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'usage_logs';
|
||||||
|
protected $primaryKey = 'request_id';
|
||||||
public $incrementing = false;
|
public $incrementing = false;
|
||||||
protected $keyType = 'string';
|
protected $keyType = 'string';
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'id',
|
'request_id',
|
||||||
'api_key_id',
|
|
||||||
'user_id',
|
'user_id',
|
||||||
'timestamp',
|
'api_key',
|
||||||
'model',
|
'model',
|
||||||
'provider',
|
'provider',
|
||||||
'endpoint',
|
'endpoint',
|
||||||
@@ -25,17 +28,22 @@ class UsageLog extends Model
|
|||||||
'cost',
|
'cost',
|
||||||
'status',
|
'status',
|
||||||
'error_message',
|
'error_message',
|
||||||
|
'timestamp',
|
||||||
|
'metadata',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected $casts = [
|
||||||
{
|
|
||||||
return [
|
|
||||||
'timestamp' => 'datetime',
|
|
||||||
'prompt_tokens' => 'integer',
|
'prompt_tokens' => 'integer',
|
||||||
'completion_tokens' => 'integer',
|
'completion_tokens' => 'integer',
|
||||||
'total_tokens' => 'integer',
|
'total_tokens' => 'integer',
|
||||||
'cost' => 'double',
|
'cost' => 'decimal:6',
|
||||||
|
'timestamp' => 'datetime',
|
||||||
|
'metadata' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(GatewayUser::class, 'user_id', 'user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function gatewayUser()
|
public function gatewayUser()
|
||||||
@@ -45,9 +53,10 @@ class UsageLog extends Model
|
|||||||
|
|
||||||
public function apiKey()
|
public function apiKey()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(ApiKey::class, 'api_key_id', 'id');
|
return $this->belongsTo(ApiKey::class, 'api_key', 'token');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scopes
|
||||||
public function scopeSuccess($query)
|
public function scopeSuccess($query)
|
||||||
{
|
{
|
||||||
return $query->where('status', 'success');
|
return $query->where('status', 'success');
|
||||||
@@ -55,21 +64,6 @@ class UsageLog extends Model
|
|||||||
|
|
||||||
public function scopeFailed($query)
|
public function scopeFailed($query)
|
||||||
{
|
{
|
||||||
return $query->where('status', '!=', 'success');
|
return $query->where('status', 'failed');
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeToday($query)
|
|
||||||
{
|
|
||||||
return $query->whereDate('timestamp', today());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeDateRange($query, $start, $end)
|
|
||||||
{
|
|
||||||
return $query->whereBetween('timestamp', [$start, $end]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCostFormattedAttribute()
|
|
||||||
{
|
|
||||||
return $this->cost ? '$' . number_format($this->cost, 4) : 'N/A';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,4 +45,36 @@ class User extends Authenticatable
|
|||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's budget
|
||||||
|
*/
|
||||||
|
public function budget()
|
||||||
|
{
|
||||||
|
return $this->hasOne(UserBudget::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's rate limit
|
||||||
|
*/
|
||||||
|
public function rateLimit()
|
||||||
|
{
|
||||||
|
return $this->hasOne(RateLimit::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's provider credentials
|
||||||
|
*/
|
||||||
|
public function providerCredentials()
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserProviderCredential::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's LLM requests
|
||||||
|
*/
|
||||||
|
public function llmRequests()
|
||||||
|
{
|
||||||
|
return $this->hasMany(LlmRequest::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
laravel-app/app/Models/UserBudget.php
Normal file
69
laravel-app/app/Models/UserBudget.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UserBudget extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'monthly_limit',
|
||||||
|
'daily_limit',
|
||||||
|
'current_month_spending',
|
||||||
|
'current_day_spending',
|
||||||
|
'month_started_at',
|
||||||
|
'day_started_at',
|
||||||
|
'alert_threshold_percentage',
|
||||||
|
'last_alert_sent_at',
|
||||||
|
'is_budget_exceeded',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'monthly_limit' => 'decimal:2',
|
||||||
|
'daily_limit' => 'decimal:2',
|
||||||
|
'current_month_spending' => 'decimal:2',
|
||||||
|
'current_day_spending' => 'decimal:2',
|
||||||
|
'month_started_at' => 'date',
|
||||||
|
'day_started_at' => 'date',
|
||||||
|
'alert_threshold_percentage' => 'integer',
|
||||||
|
'last_alert_sent_at' => 'datetime',
|
||||||
|
'is_budget_exceeded' => 'boolean',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRemainingMonthlyBudget(): float
|
||||||
|
{
|
||||||
|
return max(0, $this->monthly_limit - $this->current_month_spending);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRemainingDailyBudget(): ?float
|
||||||
|
{
|
||||||
|
if (!$this->daily_limit) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return max(0, $this->daily_limit - $this->current_day_spending);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMonthlyUsagePercentage(): float
|
||||||
|
{
|
||||||
|
if ($this->monthly_limit == 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return ($this->current_month_spending / $this->monthly_limit) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shouldSendAlert(): bool
|
||||||
|
{
|
||||||
|
$percentage = $this->getMonthlyUsagePercentage();
|
||||||
|
return $percentage >= $this->alert_threshold_percentage
|
||||||
|
&& (!$this->last_alert_sent_at || $this->last_alert_sent_at->lt(now()->subHours(24)));
|
||||||
|
}
|
||||||
|
}
|
||||||
50
laravel-app/app/Models/UserProviderCredential.php
Normal file
50
laravel-app/app/Models/UserProviderCredential.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
|
||||||
|
class UserProviderCredential extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'provider',
|
||||||
|
'api_key',
|
||||||
|
'organization_id',
|
||||||
|
'is_active',
|
||||||
|
'last_used_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $hidden = [
|
||||||
|
'api_key',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'last_used_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Automatic encryption when setting
|
||||||
|
public function setApiKeyAttribute($value): void
|
||||||
|
{
|
||||||
|
$this->attributes['api_key'] = Crypt::encryptString($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatic decryption when getting
|
||||||
|
public function getApiKeyAttribute($value): string
|
||||||
|
{
|
||||||
|
return Crypt::decryptString($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsUsed(): void
|
||||||
|
{
|
||||||
|
$this->update(['last_used_at' => now()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
213
laravel-app/app/Services/Budget/BudgetChecker.php
Normal file
213
laravel-app/app/Services/Budget/BudgetChecker.php
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Budget;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserBudget;
|
||||||
|
use App\Exceptions\InsufficientBudgetException;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class BudgetChecker
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check if user has sufficient budget for a request
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @param float $estimatedCost
|
||||||
|
* @return bool
|
||||||
|
* @throws InsufficientBudgetException
|
||||||
|
*/
|
||||||
|
public function checkBudget(User $user, float $estimatedCost = 0.0): bool
|
||||||
|
{
|
||||||
|
$budget = $this->getOrCreateBudget($user);
|
||||||
|
|
||||||
|
// If budget is already exceeded, deny immediately
|
||||||
|
if ($budget->is_budget_exceeded) {
|
||||||
|
throw new InsufficientBudgetException(
|
||||||
|
"Budget limit exceeded. Current spending: $" . number_format($budget->current_month_spending, 2) .
|
||||||
|
" / Monthly limit: $" . number_format($budget->monthly_limit, 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check daily limit if set
|
||||||
|
if ($budget->daily_limit > 0) {
|
||||||
|
$projectedDailySpending = $budget->current_day_spending + $estimatedCost;
|
||||||
|
|
||||||
|
if ($projectedDailySpending > $budget->daily_limit) {
|
||||||
|
throw new InsufficientBudgetException(
|
||||||
|
"Daily budget limit would be exceeded. Current: $" . number_format($budget->current_day_spending, 2) .
|
||||||
|
" / Daily limit: $" . number_format($budget->daily_limit, 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check monthly limit
|
||||||
|
if ($budget->monthly_limit > 0) {
|
||||||
|
$projectedMonthlySpending = $budget->current_month_spending + $estimatedCost;
|
||||||
|
|
||||||
|
if ($projectedMonthlySpending > $budget->monthly_limit) {
|
||||||
|
throw new InsufficientBudgetException(
|
||||||
|
"Monthly budget limit would be exceeded. Current: $" . number_format($budget->current_month_spending, 2) .
|
||||||
|
" / Monthly limit: $" . number_format($budget->monthly_limit, 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check alert threshold
|
||||||
|
$usagePercentage = ($projectedMonthlySpending / $budget->monthly_limit) * 100;
|
||||||
|
|
||||||
|
if ($usagePercentage >= $budget->alert_threshold_percentage) {
|
||||||
|
$this->sendBudgetAlert($user, $budget, $usagePercentage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user budget after a request
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @param float $actualCost
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function updateBudget(User $user, float $actualCost): void
|
||||||
|
{
|
||||||
|
$budget = $this->getOrCreateBudget($user);
|
||||||
|
|
||||||
|
// Reset periods if needed
|
||||||
|
$this->checkAndResetPeriods($budget);
|
||||||
|
|
||||||
|
// Update spending
|
||||||
|
$budget->current_month_spending += $actualCost;
|
||||||
|
$budget->current_day_spending += $actualCost;
|
||||||
|
|
||||||
|
// Check if budget is now exceeded
|
||||||
|
if ($budget->monthly_limit > 0 && $budget->current_month_spending >= $budget->monthly_limit) {
|
||||||
|
$budget->is_budget_exceeded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$budget->save();
|
||||||
|
|
||||||
|
// Invalidate cache
|
||||||
|
Cache::forget("user_budget:{$user->id}");
|
||||||
|
|
||||||
|
Log::info('Budget updated', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'cost' => $actualCost,
|
||||||
|
'monthly_spending' => $budget->current_month_spending,
|
||||||
|
'daily_spending' => $budget->current_day_spending
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create user budget
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @return UserBudget
|
||||||
|
*/
|
||||||
|
private function getOrCreateBudget(User $user): UserBudget
|
||||||
|
{
|
||||||
|
$budget = $user->budget;
|
||||||
|
|
||||||
|
if (!$budget) {
|
||||||
|
$budget = UserBudget::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'monthly_limit' => config('llm.default_monthly_budget', 100.00),
|
||||||
|
'daily_limit' => config('llm.default_daily_budget', 10.00),
|
||||||
|
'month_started_at' => now()->startOfMonth(),
|
||||||
|
'day_started_at' => now()->startOfDay(),
|
||||||
|
'alert_threshold_percentage' => 80,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('Budget created for user', ['user_id' => $user->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $budget;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check and reset budget periods if needed
|
||||||
|
*
|
||||||
|
* @param UserBudget $budget
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function checkAndResetPeriods(UserBudget $budget): void
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
// Reset monthly budget if new month
|
||||||
|
if ($now->startOfMonth()->greaterThan($budget->month_started_at)) {
|
||||||
|
$budget->current_month_spending = 0.0;
|
||||||
|
$budget->month_started_at = $now->startOfMonth();
|
||||||
|
$budget->is_budget_exceeded = false;
|
||||||
|
$budget->last_alert_sent_at = null;
|
||||||
|
|
||||||
|
Log::info('Monthly budget reset', ['user_id' => $budget->user_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset daily budget if new day
|
||||||
|
if ($now->startOfDay()->greaterThan($budget->day_started_at)) {
|
||||||
|
$budget->current_day_spending = 0.0;
|
||||||
|
$budget->day_started_at = $now->startOfDay();
|
||||||
|
|
||||||
|
Log::info('Daily budget reset', ['user_id' => $budget->user_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send budget alert to user
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @param UserBudget $budget
|
||||||
|
* @param float $usagePercentage
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function sendBudgetAlert(User $user, UserBudget $budget, float $usagePercentage): void
|
||||||
|
{
|
||||||
|
// Only send alert once per day
|
||||||
|
if ($budget->last_alert_sent_at && $budget->last_alert_sent_at->isToday()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning('Budget threshold reached', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'user_email' => $user->email,
|
||||||
|
'usage_percentage' => round($usagePercentage, 2),
|
||||||
|
'current_spending' => $budget->current_month_spending,
|
||||||
|
'monthly_limit' => $budget->monthly_limit
|
||||||
|
]);
|
||||||
|
|
||||||
|
// TODO: Send email notification
|
||||||
|
// Mail::to($user->email)->send(new BudgetAlertMail($budget, $usagePercentage));
|
||||||
|
|
||||||
|
$budget->last_alert_sent_at = now();
|
||||||
|
$budget->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get budget status for user
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getBudgetStatus(User $user): array
|
||||||
|
{
|
||||||
|
$budget = $this->getOrCreateBudget($user);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'monthly_limit' => $budget->monthly_limit,
|
||||||
|
'daily_limit' => $budget->daily_limit,
|
||||||
|
'current_month_spending' => $budget->current_month_spending,
|
||||||
|
'current_day_spending' => $budget->current_day_spending,
|
||||||
|
'monthly_remaining' => max(0, $budget->monthly_limit - $budget->current_month_spending),
|
||||||
|
'daily_remaining' => max(0, $budget->daily_limit - $budget->current_day_spending),
|
||||||
|
'monthly_usage_percentage' => $budget->monthly_limit > 0
|
||||||
|
? ($budget->current_month_spending / $budget->monthly_limit) * 100
|
||||||
|
: 0,
|
||||||
|
'is_exceeded' => $budget->is_budget_exceeded,
|
||||||
|
'month_started_at' => $budget->month_started_at,
|
||||||
|
'day_started_at' => $budget->day_started_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
48
laravel-app/app/Services/LLM/Contracts/ProviderInterface.php
Normal file
48
laravel-app/app/Services/LLM/Contracts/ProviderInterface.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\LLM\Contracts;
|
||||||
|
|
||||||
|
interface ProviderInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Send a chat completion request to the provider
|
||||||
|
*
|
||||||
|
* @param array $messages Array of message objects with 'role' and 'content'
|
||||||
|
* @param array $options Additional options (model, temperature, max_tokens, etc.)
|
||||||
|
* @return array Raw provider response
|
||||||
|
* @throws \App\Exceptions\ProviderException
|
||||||
|
*/
|
||||||
|
public function chatCompletion(array $messages, array $options = []): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize provider response to common format
|
||||||
|
*
|
||||||
|
* @param array $response Raw provider response
|
||||||
|
* @return array Normalized response with: id, model, content, usage, finish_reason
|
||||||
|
*/
|
||||||
|
public function normalizeResponse(array $response): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate cost for given token usage
|
||||||
|
*
|
||||||
|
* @param int $promptTokens Number of prompt tokens
|
||||||
|
* @param int $completionTokens Number of completion tokens
|
||||||
|
* @param string $model Model name
|
||||||
|
* @return float Total cost in USD
|
||||||
|
*/
|
||||||
|
public function calculateCost(int $promptTokens, int $completionTokens, string $model): float;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get supported models for this provider
|
||||||
|
*
|
||||||
|
* @return array List of supported model names
|
||||||
|
*/
|
||||||
|
public function getSupportedModels(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate API key
|
||||||
|
*
|
||||||
|
* @return bool True if API key is valid
|
||||||
|
*/
|
||||||
|
public function validateApiKey(): bool;
|
||||||
|
}
|
||||||
128
laravel-app/app/Services/LLM/CostCalculator.php
Normal file
128
laravel-app/app/Services/LLM/CostCalculator.php
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\LLM;
|
||||||
|
|
||||||
|
use App\Models\ModelPricing;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class CostCalculator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Calculate cost for a specific provider and model
|
||||||
|
*
|
||||||
|
* @param string $provider Provider name (openai, anthropic, etc.)
|
||||||
|
* @param string $model Model name
|
||||||
|
* @param int $promptTokens Number of prompt tokens
|
||||||
|
* @param int $completionTokens Number of completion tokens
|
||||||
|
* @return array ['prompt_cost', 'completion_cost', 'total_cost']
|
||||||
|
*/
|
||||||
|
public function calculate(
|
||||||
|
string $provider,
|
||||||
|
string $model,
|
||||||
|
int $promptTokens,
|
||||||
|
int $completionTokens
|
||||||
|
): array {
|
||||||
|
$pricing = $this->getPricing($provider, $model);
|
||||||
|
|
||||||
|
if (!$pricing) {
|
||||||
|
Log::warning("No pricing found for {$provider}/{$model}, returning zero cost");
|
||||||
|
return [
|
||||||
|
'prompt_cost' => 0.0,
|
||||||
|
'completion_cost' => 0.0,
|
||||||
|
'total_cost' => 0.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$promptCost = ($promptTokens / 1_000_000) * $pricing->input_price_per_million;
|
||||||
|
$completionCost = ($completionTokens / 1_000_000) * $pricing->output_price_per_million;
|
||||||
|
$totalCost = $promptCost + $completionCost;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'prompt_cost' => round($promptCost, 6),
|
||||||
|
'completion_cost' => round($completionCost, 6),
|
||||||
|
'total_cost' => round($totalCost, 6),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate cost before making the request
|
||||||
|
* Uses average token estimation
|
||||||
|
*
|
||||||
|
* @param string $provider
|
||||||
|
* @param string $model
|
||||||
|
* @param int $estimatedPromptTokens
|
||||||
|
* @param int $estimatedCompletionTokens
|
||||||
|
* @return float Estimated total cost
|
||||||
|
*/
|
||||||
|
public function estimateCost(
|
||||||
|
string $provider,
|
||||||
|
string $model,
|
||||||
|
int $estimatedPromptTokens,
|
||||||
|
int $estimatedCompletionTokens
|
||||||
|
): float {
|
||||||
|
$costs = $this->calculate($provider, $model, $estimatedPromptTokens, $estimatedCompletionTokens);
|
||||||
|
return $costs['total_cost'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pricing from cache or database
|
||||||
|
*
|
||||||
|
* @param string $provider
|
||||||
|
* @param string $model
|
||||||
|
* @return ModelPricing|null
|
||||||
|
*/
|
||||||
|
private function getPricing(string $provider, string $model): ?ModelPricing
|
||||||
|
{
|
||||||
|
$cacheKey = "pricing:{$provider}:{$model}";
|
||||||
|
$cacheTTL = 3600; // 1 hour
|
||||||
|
|
||||||
|
return Cache::remember($cacheKey, $cacheTTL, function () use ($provider, $model) {
|
||||||
|
return ModelPricing::where('provider', $provider)
|
||||||
|
->where('model', $model)
|
||||||
|
->where('is_active', true)
|
||||||
|
->where('effective_from', '<=', now())
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->whereNull('effective_until')
|
||||||
|
->orWhere('effective_until', '>=', now());
|
||||||
|
})
|
||||||
|
->orderBy('effective_from', 'desc')
|
||||||
|
->first();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear pricing cache for a specific provider/model
|
||||||
|
*
|
||||||
|
* @param string|null $provider
|
||||||
|
* @param string|null $model
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function clearCache(?string $provider = null, ?string $model = null): void
|
||||||
|
{
|
||||||
|
if ($provider && $model) {
|
||||||
|
Cache::forget("pricing:{$provider}:{$model}");
|
||||||
|
} else {
|
||||||
|
// Clear all pricing cache
|
||||||
|
Cache::flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active pricing entries
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Support\Collection
|
||||||
|
*/
|
||||||
|
public function getAllActivePricing(): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
return ModelPricing::where('is_active', true)
|
||||||
|
->where('effective_from', '<=', now())
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->whereNull('effective_until')
|
||||||
|
->orWhere('effective_until', '>=', now());
|
||||||
|
})
|
||||||
|
->orderBy('provider')
|
||||||
|
->orderBy('model')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
172
laravel-app/app/Services/LLM/GatewayService.php
Normal file
172
laravel-app/app/Services/LLM/GatewayService.php
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\LLM;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserProviderCredential;
|
||||||
|
use App\Exceptions\{ProviderException, InsufficientBudgetException, RateLimitExceededException};
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class GatewayService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CostCalculator $costCalculator,
|
||||||
|
private RequestLogger $requestLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a chat completion request through the gateway
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @param string $provider
|
||||||
|
* @param string $model
|
||||||
|
* @param array $messages
|
||||||
|
* @param array $options
|
||||||
|
* @param string|null $ipAddress
|
||||||
|
* @param string|null $userAgent
|
||||||
|
* @return array
|
||||||
|
* @throws ProviderException
|
||||||
|
* @throws InsufficientBudgetException
|
||||||
|
*/
|
||||||
|
public function chatCompletion(
|
||||||
|
User $user,
|
||||||
|
string $provider,
|
||||||
|
string $model,
|
||||||
|
array $messages,
|
||||||
|
array $options = [],
|
||||||
|
?string $ipAddress = null,
|
||||||
|
?string $userAgent = null
|
||||||
|
): array {
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
// 1. Get user's API credentials
|
||||||
|
$credential = $this->getUserCredential($user, $provider);
|
||||||
|
|
||||||
|
// 2. Create provider instance
|
||||||
|
$providerInstance = ProviderFactory::create($provider, $credential->api_key);
|
||||||
|
|
||||||
|
// 3. Build request payload
|
||||||
|
$requestPayload = [
|
||||||
|
'provider' => $provider,
|
||||||
|
'model' => $model,
|
||||||
|
'messages' => $messages,
|
||||||
|
'options' => $options,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 4. Make the API request
|
||||||
|
$response = $providerInstance->chatCompletion($messages, array_merge($options, ['model' => $model]));
|
||||||
|
|
||||||
|
// 5. Normalize response
|
||||||
|
$normalized = $providerInstance->normalizeResponse($response);
|
||||||
|
|
||||||
|
// 6. Calculate response time
|
||||||
|
$responseTimeMs = (int) round((microtime(true) - $startTime) * 1000);
|
||||||
|
|
||||||
|
// 7. Calculate costs
|
||||||
|
$costs = $this->costCalculator->calculate(
|
||||||
|
$provider,
|
||||||
|
$normalized['model'],
|
||||||
|
$normalized['usage']['prompt_tokens'],
|
||||||
|
$normalized['usage']['completion_tokens']
|
||||||
|
);
|
||||||
|
|
||||||
|
// 8. Log request asynchronously
|
||||||
|
$requestId = $this->requestLogger->logSuccess(
|
||||||
|
$user->id,
|
||||||
|
$provider,
|
||||||
|
$normalized['model'],
|
||||||
|
$requestPayload,
|
||||||
|
$normalized,
|
||||||
|
$costs,
|
||||||
|
$responseTimeMs,
|
||||||
|
$ipAddress,
|
||||||
|
$userAgent
|
||||||
|
);
|
||||||
|
|
||||||
|
// 9. Update user budget (synchronously for accuracy)
|
||||||
|
$this->updateUserBudget($user, $costs['total_cost']);
|
||||||
|
|
||||||
|
// 10. Return response with metadata
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'request_id' => $requestId,
|
||||||
|
'provider' => $provider,
|
||||||
|
'model' => $normalized['model'],
|
||||||
|
'content' => $normalized['content'],
|
||||||
|
'role' => $normalized['role'],
|
||||||
|
'finish_reason' => $normalized['finish_reason'],
|
||||||
|
'usage' => $normalized['usage'],
|
||||||
|
'cost' => $costs,
|
||||||
|
'response_time_ms' => $responseTimeMs,
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (ProviderException $e) {
|
||||||
|
// Log failure
|
||||||
|
$this->requestLogger->logFailure(
|
||||||
|
$user->id,
|
||||||
|
$provider,
|
||||||
|
$model,
|
||||||
|
$requestPayload,
|
||||||
|
$e->getMessage(),
|
||||||
|
$e->getCode(),
|
||||||
|
$ipAddress,
|
||||||
|
$userAgent
|
||||||
|
);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's credential for a provider
|
||||||
|
*/
|
||||||
|
private function getUserCredential(User $user, string $provider): UserProviderCredential
|
||||||
|
{
|
||||||
|
$credential = UserProviderCredential::where('user_id', $user->id)
|
||||||
|
->where('provider', $provider)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$credential) {
|
||||||
|
throw new ProviderException(
|
||||||
|
"No active API credentials found for provider: {$provider}",
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last used timestamp
|
||||||
|
$credential->update(['last_used_at' => now()]);
|
||||||
|
|
||||||
|
return $credential;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user's budget with spending
|
||||||
|
*/
|
||||||
|
private function updateUserBudget(User $user, float $cost): void
|
||||||
|
{
|
||||||
|
$budget = $user->budget;
|
||||||
|
|
||||||
|
if (!$budget) {
|
||||||
|
return; // No budget configured
|
||||||
|
}
|
||||||
|
|
||||||
|
$budget->increment('current_month_spending', $cost);
|
||||||
|
$budget->increment('current_day_spending', $cost);
|
||||||
|
|
||||||
|
// Check if budget exceeded
|
||||||
|
if ($budget->current_month_spending >= $budget->monthly_limit) {
|
||||||
|
$budget->update(['is_budget_exceeded' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check alert threshold
|
||||||
|
if ($budget->alert_threshold_percentage) {
|
||||||
|
$threshold = $budget->monthly_limit * ($budget->alert_threshold_percentage / 100);
|
||||||
|
if ($budget->current_month_spending >= $threshold && !$budget->last_alert_sent_at) {
|
||||||
|
// TODO: Dispatch alert notification
|
||||||
|
$budget->update(['last_alert_sent_at' => now()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
laravel-app/app/Services/LLM/ProviderFactory.php
Normal file
62
laravel-app/app/Services/LLM/ProviderFactory.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\LLM;
|
||||||
|
|
||||||
|
use App\Services\LLM\Contracts\ProviderInterface;
|
||||||
|
use App\Services\LLM\Providers\{
|
||||||
|
OpenAIProvider,
|
||||||
|
AnthropicProvider,
|
||||||
|
MistralProvider,
|
||||||
|
GeminiProvider,
|
||||||
|
DeepSeekProvider
|
||||||
|
};
|
||||||
|
|
||||||
|
class ProviderFactory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a provider instance
|
||||||
|
*
|
||||||
|
* @param string $provider Provider name (openai, anthropic, mistral, gemini, deepseek)
|
||||||
|
* @param string $apiKey API key for the provider
|
||||||
|
* @return ProviderInterface
|
||||||
|
* @throws \InvalidArgumentException
|
||||||
|
*/
|
||||||
|
public static function create(string $provider, string $apiKey): ProviderInterface
|
||||||
|
{
|
||||||
|
return match (strtolower($provider)) {
|
||||||
|
'openai' => new OpenAIProvider($apiKey),
|
||||||
|
'anthropic' => new AnthropicProvider($apiKey),
|
||||||
|
'mistral' => new MistralProvider($apiKey),
|
||||||
|
'gemini' => new GeminiProvider($apiKey),
|
||||||
|
'deepseek' => new DeepSeekProvider($apiKey),
|
||||||
|
default => throw new \InvalidArgumentException("Unknown provider: {$provider}")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of supported providers
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function getSupportedProviders(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'openai',
|
||||||
|
'anthropic',
|
||||||
|
'mistral',
|
||||||
|
'gemini',
|
||||||
|
'deepseek',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a provider is supported
|
||||||
|
*
|
||||||
|
* @param string $provider
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function isSupported(string $provider): bool
|
||||||
|
{
|
||||||
|
return in_array(strtolower($provider), self::getSupportedProviders());
|
||||||
|
}
|
||||||
|
}
|
||||||
103
laravel-app/app/Services/LLM/Providers/AbstractProvider.php
Normal file
103
laravel-app/app/Services/LLM/Providers/AbstractProvider.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\LLM\Providers;
|
||||||
|
|
||||||
|
use App\Services\LLM\Contracts\ProviderInterface;
|
||||||
|
use App\Exceptions\ProviderException;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
abstract class AbstractProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
protected string $apiKey;
|
||||||
|
protected string $baseUrl;
|
||||||
|
protected int $timeout = 60;
|
||||||
|
protected int $retryAttempts = 3;
|
||||||
|
protected int $retryDelay = 1000; // milliseconds
|
||||||
|
|
||||||
|
public function __construct(string $apiKey)
|
||||||
|
{
|
||||||
|
$this->apiKey = $apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build request payload for provider
|
||||||
|
*/
|
||||||
|
abstract protected function buildRequest(array $messages, array $options): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get authorization headers for provider
|
||||||
|
*/
|
||||||
|
abstract protected function getAuthHeaders(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make HTTP request with retry logic
|
||||||
|
*/
|
||||||
|
protected function makeRequest(string $endpoint, array $data): array
|
||||||
|
{
|
||||||
|
$attempt = 0;
|
||||||
|
$lastException = null;
|
||||||
|
|
||||||
|
while ($attempt < $this->retryAttempts) {
|
||||||
|
try {
|
||||||
|
$response = Http::withHeaders($this->getAuthHeaders())
|
||||||
|
->timeout($this->timeout)
|
||||||
|
->post($this->baseUrl . $endpoint, $data);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return $response->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle specific HTTP errors
|
||||||
|
if ($response->status() === 401) {
|
||||||
|
throw new ProviderException('Invalid API key', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->status() === 429) {
|
||||||
|
throw new ProviderException('Rate limit exceeded', 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->status() >= 500) {
|
||||||
|
throw new ProviderException('Provider server error', $response->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ProviderException(
|
||||||
|
'Request failed: ' . $response->body(),
|
||||||
|
$response->status()
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$lastException = $e;
|
||||||
|
$attempt++;
|
||||||
|
|
||||||
|
if ($attempt < $this->retryAttempts) {
|
||||||
|
Log::warning("Provider request failed, retrying ({$attempt}/{$this->retryAttempts})", [
|
||||||
|
'provider' => static::class,
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
usleep($this->retryDelay * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ProviderException(
|
||||||
|
'All retry attempts failed: ' . ($lastException ? $lastException->getMessage() : 'Unknown error'),
|
||||||
|
$lastException ? $lastException->getCode() : 500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate API key by making a test request
|
||||||
|
*/
|
||||||
|
public function validateApiKey(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->chatCompletion([
|
||||||
|
['role' => 'user', 'content' => 'test']
|
||||||
|
], ['max_tokens' => 5]);
|
||||||
|
return true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
laravel-app/app/Services/LLM/Providers/AnthropicProvider.php
Normal file
113
laravel-app/app/Services/LLM/Providers/AnthropicProvider.php
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\LLM\Providers;
|
||||||
|
|
||||||
|
use App\Models\ModelPricing;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class AnthropicProvider extends AbstractProvider
|
||||||
|
{
|
||||||
|
protected string $baseUrl = 'https://api.anthropic.com/v1';
|
||||||
|
private string $apiVersion = '2023-06-01';
|
||||||
|
|
||||||
|
protected function buildRequest(array $messages, array $options): array
|
||||||
|
{
|
||||||
|
// Anthropic requires system message separate
|
||||||
|
$systemMessage = null;
|
||||||
|
$formattedMessages = [];
|
||||||
|
|
||||||
|
foreach ($messages as $message) {
|
||||||
|
if ($message['role'] === 'system') {
|
||||||
|
$systemMessage = $message['content'];
|
||||||
|
} else {
|
||||||
|
$formattedMessages[] = $message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = array_filter([
|
||||||
|
'model' => $options['model'] ?? 'claude-sonnet-4',
|
||||||
|
'max_tokens' => $options['max_tokens'] ?? 4096,
|
||||||
|
'messages' => $formattedMessages,
|
||||||
|
'system' => $systemMessage,
|
||||||
|
'temperature' => $options['temperature'] ?? null,
|
||||||
|
'top_p' => $options['top_p'] ?? null,
|
||||||
|
'stop_sequences' => $options['stop'] ?? null,
|
||||||
|
], fn($value) => $value !== null);
|
||||||
|
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getAuthHeaders(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'x-api-key' => $this->apiKey,
|
||||||
|
'anthropic-version' => $this->apiVersion,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function chatCompletion(array $messages, array $options = []): array
|
||||||
|
{
|
||||||
|
$data = $this->buildRequest($messages, $options);
|
||||||
|
return $this->makeRequest('/messages', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeResponse(array $response): array
|
||||||
|
{
|
||||||
|
$content = '';
|
||||||
|
if (isset($response['content']) && is_array($response['content'])) {
|
||||||
|
foreach ($response['content'] as $block) {
|
||||||
|
if ($block['type'] === 'text') {
|
||||||
|
$content .= $block['text'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $response['id'] ?? null,
|
||||||
|
'model' => $response['model'] ?? null,
|
||||||
|
'content' => $content,
|
||||||
|
'role' => $response['role'] ?? 'assistant',
|
||||||
|
'finish_reason' => $response['stop_reason'] ?? null,
|
||||||
|
'usage' => [
|
||||||
|
'prompt_tokens' => $response['usage']['input_tokens'] ?? 0,
|
||||||
|
'completion_tokens' => $response['usage']['output_tokens'] ?? 0,
|
||||||
|
'total_tokens' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
|
||||||
|
],
|
||||||
|
'raw_response' => $response,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function calculateCost(int $promptTokens, int $completionTokens, string $model): float
|
||||||
|
{
|
||||||
|
$cacheKey = "pricing:anthropic:{$model}";
|
||||||
|
|
||||||
|
$pricing = Cache::remember($cacheKey, 3600, function () use ($model) {
|
||||||
|
return ModelPricing::where('provider', 'anthropic')
|
||||||
|
->where('model', $model)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!$pricing) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$promptCost = ($promptTokens / 1_000_000) * $pricing->input_price_per_million;
|
||||||
|
$completionCost = ($completionTokens / 1_000_000) * $pricing->output_price_per_million;
|
||||||
|
|
||||||
|
return round($promptCost + $completionCost, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSupportedModels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'claude-opus-4',
|
||||||
|
'claude-sonnet-4',
|
||||||
|
'claude-haiku-4',
|
||||||
|
'claude-3-opus',
|
||||||
|
'claude-3-sonnet',
|
||||||
|
'claude-3-haiku',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
87
laravel-app/app/Services/LLM/Providers/DeepSeekProvider.php
Normal file
87
laravel-app/app/Services/LLM/Providers/DeepSeekProvider.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\LLM\Providers;
|
||||||
|
|
||||||
|
use App\Models\ModelPricing;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class DeepSeekProvider extends AbstractProvider
|
||||||
|
{
|
||||||
|
protected string $baseUrl = 'https://api.deepseek.com/v1';
|
||||||
|
|
||||||
|
protected function buildRequest(array $messages, array $options): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
'model' => $options['model'] ?? 'deepseek-chat',
|
||||||
|
'messages' => $messages,
|
||||||
|
'temperature' => $options['temperature'] ?? 0.7,
|
||||||
|
'max_tokens' => $options['max_tokens'] ?? null,
|
||||||
|
'top_p' => $options['top_p'] ?? null,
|
||||||
|
'frequency_penalty' => $options['frequency_penalty'] ?? null,
|
||||||
|
'presence_penalty' => $options['presence_penalty'] ?? null,
|
||||||
|
'stop' => $options['stop'] ?? null,
|
||||||
|
'stream' => false,
|
||||||
|
], fn($value) => $value !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getAuthHeaders(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Authorization' => 'Bearer ' . $this->apiKey,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function chatCompletion(array $messages, array $options = []): array
|
||||||
|
{
|
||||||
|
$data = $this->buildRequest($messages, $options);
|
||||||
|
return $this->makeRequest('/chat/completions', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeResponse(array $response): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $response['id'] ?? null,
|
||||||
|
'model' => $response['model'] ?? null,
|
||||||
|
'content' => $response['choices'][0]['message']['content'] ?? '',
|
||||||
|
'role' => $response['choices'][0]['message']['role'] ?? 'assistant',
|
||||||
|
'finish_reason' => $response['choices'][0]['finish_reason'] ?? null,
|
||||||
|
'usage' => [
|
||||||
|
'prompt_tokens' => $response['usage']['prompt_tokens'] ?? 0,
|
||||||
|
'completion_tokens' => $response['usage']['completion_tokens'] ?? 0,
|
||||||
|
'total_tokens' => $response['usage']['total_tokens'] ?? 0,
|
||||||
|
],
|
||||||
|
'raw_response' => $response,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function calculateCost(int $promptTokens, int $completionTokens, string $model): float
|
||||||
|
{
|
||||||
|
$cacheKey = "pricing:deepseek:{$model}";
|
||||||
|
|
||||||
|
$pricing = Cache::remember($cacheKey, 3600, function () use ($model) {
|
||||||
|
return ModelPricing::where('provider', 'deepseek')
|
||||||
|
->where('model', $model)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!$pricing) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$promptCost = ($promptTokens / 1_000_000) * $pricing->input_price_per_million;
|
||||||
|
$completionCost = ($completionTokens / 1_000_000) * $pricing->output_price_per_million;
|
||||||
|
|
||||||
|
return round($promptCost + $completionCost, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSupportedModels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'deepseek-chat',
|
||||||
|
'deepseek-coder',
|
||||||
|
'deepseek-reasoner',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
132
laravel-app/app/Services/LLM/Providers/GeminiProvider.php
Normal file
132
laravel-app/app/Services/LLM/Providers/GeminiProvider.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\LLM\Providers;
|
||||||
|
|
||||||
|
use App\Models\ModelPricing;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class GeminiProvider extends AbstractProvider
|
||||||
|
{
|
||||||
|
protected string $baseUrl = 'https://generativelanguage.googleapis.com/v1beta';
|
||||||
|
|
||||||
|
protected function buildRequest(array $messages, array $options): array
|
||||||
|
{
|
||||||
|
// Gemini uses a different message format
|
||||||
|
$contents = [];
|
||||||
|
|
||||||
|
foreach ($messages as $message) {
|
||||||
|
$role = $message['role'];
|
||||||
|
|
||||||
|
// Gemini uses 'model' instead of 'assistant' and doesn't support 'system'
|
||||||
|
if ($role === 'assistant') {
|
||||||
|
$role = 'model';
|
||||||
|
} elseif ($role === 'system') {
|
||||||
|
// Convert system messages to user messages with context
|
||||||
|
$role = 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents[] = [
|
||||||
|
'role' => $role,
|
||||||
|
'parts' => [
|
||||||
|
['text' => $message['content']]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = [
|
||||||
|
'contents' => $contents,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add generation config if options provided
|
||||||
|
$generationConfig = array_filter([
|
||||||
|
'temperature' => $options['temperature'] ?? null,
|
||||||
|
'maxOutputTokens' => $options['max_tokens'] ?? null,
|
||||||
|
'topP' => $options['top_p'] ?? null,
|
||||||
|
'stopSequences' => $options['stop'] ?? null,
|
||||||
|
], fn($value) => $value !== null);
|
||||||
|
|
||||||
|
if (!empty($generationConfig)) {
|
||||||
|
$request['generationConfig'] = $generationConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getAuthHeaders(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function chatCompletion(array $messages, array $options = []): array
|
||||||
|
{
|
||||||
|
$model = $options['model'] ?? 'gemini-pro';
|
||||||
|
$data = $this->buildRequest($messages, $options);
|
||||||
|
|
||||||
|
// Gemini uses API key as query parameter
|
||||||
|
$endpoint = "/models/{$model}:generateContent?key={$this->apiKey}";
|
||||||
|
|
||||||
|
return $this->makeRequest($endpoint, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeResponse(array $response): array
|
||||||
|
{
|
||||||
|
$candidate = $response['candidates'][0] ?? [];
|
||||||
|
$content = $candidate['content'] ?? [];
|
||||||
|
$parts = $content['parts'] ?? [];
|
||||||
|
|
||||||
|
$textContent = '';
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$textContent .= $part['text'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$usageMetadata = $response['usageMetadata'] ?? [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => null, // Gemini doesn't provide an ID
|
||||||
|
'model' => $response['modelVersion'] ?? null,
|
||||||
|
'content' => $textContent,
|
||||||
|
'role' => 'assistant',
|
||||||
|
'finish_reason' => $candidate['finishReason'] ?? null,
|
||||||
|
'usage' => [
|
||||||
|
'prompt_tokens' => $usageMetadata['promptTokenCount'] ?? 0,
|
||||||
|
'completion_tokens' => $usageMetadata['candidatesTokenCount'] ?? 0,
|
||||||
|
'total_tokens' => $usageMetadata['totalTokenCount'] ?? 0,
|
||||||
|
],
|
||||||
|
'raw_response' => $response,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function calculateCost(int $promptTokens, int $completionTokens, string $model): float
|
||||||
|
{
|
||||||
|
$cacheKey = "pricing:gemini:{$model}";
|
||||||
|
|
||||||
|
$pricing = Cache::remember($cacheKey, 3600, function () use ($model) {
|
||||||
|
return ModelPricing::where('provider', 'gemini')
|
||||||
|
->where('model', $model)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!$pricing) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$promptCost = ($promptTokens / 1_000_000) * $pricing->input_price_per_million;
|
||||||
|
$completionCost = ($completionTokens / 1_000_000) * $pricing->output_price_per_million;
|
||||||
|
|
||||||
|
return round($promptCost + $completionCost, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSupportedModels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'gemini-pro',
|
||||||
|
'gemini-pro-vision',
|
||||||
|
'gemini-1.5-pro',
|
||||||
|
'gemini-1.5-flash',
|
||||||
|
'gemini-ultra',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
90
laravel-app/app/Services/LLM/Providers/MistralProvider.php
Normal file
90
laravel-app/app/Services/LLM/Providers/MistralProvider.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\LLM\Providers;
|
||||||
|
|
||||||
|
use App\Models\ModelPricing;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class MistralProvider extends AbstractProvider
|
||||||
|
{
|
||||||
|
protected string $baseUrl = 'https://api.mistral.ai/v1';
|
||||||
|
|
||||||
|
protected function buildRequest(array $messages, array $options): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
'model' => $options['model'] ?? 'mistral-small-latest',
|
||||||
|
'messages' => $messages,
|
||||||
|
'temperature' => $options['temperature'] ?? 0.7,
|
||||||
|
'max_tokens' => $options['max_tokens'] ?? null,
|
||||||
|
'top_p' => $options['top_p'] ?? null,
|
||||||
|
'stream' => false,
|
||||||
|
'safe_prompt' => $options['safe_prompt'] ?? false,
|
||||||
|
'random_seed' => $options['random_seed'] ?? null,
|
||||||
|
], fn($value) => $value !== null && $value !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getAuthHeaders(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Authorization' => 'Bearer ' . $this->apiKey,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function chatCompletion(array $messages, array $options = []): array
|
||||||
|
{
|
||||||
|
$data = $this->buildRequest($messages, $options);
|
||||||
|
return $this->makeRequest('/chat/completions', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeResponse(array $response): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $response['id'] ?? null,
|
||||||
|
'model' => $response['model'] ?? null,
|
||||||
|
'content' => $response['choices'][0]['message']['content'] ?? '',
|
||||||
|
'role' => $response['choices'][0]['message']['role'] ?? 'assistant',
|
||||||
|
'finish_reason' => $response['choices'][0]['finish_reason'] ?? null,
|
||||||
|
'usage' => [
|
||||||
|
'prompt_tokens' => $response['usage']['prompt_tokens'] ?? 0,
|
||||||
|
'completion_tokens' => $response['usage']['completion_tokens'] ?? 0,
|
||||||
|
'total_tokens' => $response['usage']['total_tokens'] ?? 0,
|
||||||
|
],
|
||||||
|
'raw_response' => $response,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function calculateCost(int $promptTokens, int $completionTokens, string $model): float
|
||||||
|
{
|
||||||
|
$cacheKey = "pricing:mistral:{$model}";
|
||||||
|
|
||||||
|
$pricing = Cache::remember($cacheKey, 3600, function () use ($model) {
|
||||||
|
return ModelPricing::where('provider', 'mistral')
|
||||||
|
->where('model', $model)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!$pricing) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$promptCost = ($promptTokens / 1_000_000) * $pricing->input_price_per_million;
|
||||||
|
$completionCost = ($completionTokens / 1_000_000) * $pricing->output_price_per_million;
|
||||||
|
|
||||||
|
return round($promptCost + $completionCost, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSupportedModels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'mistral-large-latest',
|
||||||
|
'mistral-medium-latest',
|
||||||
|
'mistral-small-latest',
|
||||||
|
'mistral-tiny',
|
||||||
|
'open-mistral-7b',
|
||||||
|
'open-mixtral-8x7b',
|
||||||
|
'open-mixtral-8x22b',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
89
laravel-app/app/Services/LLM/Providers/OpenAIProvider.php
Normal file
89
laravel-app/app/Services/LLM/Providers/OpenAIProvider.php
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\LLM\Providers;
|
||||||
|
|
||||||
|
use App\Models\ModelPricing;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class OpenAIProvider extends AbstractProvider
|
||||||
|
{
|
||||||
|
protected string $baseUrl = 'https://api.openai.com/v1';
|
||||||
|
|
||||||
|
protected function buildRequest(array $messages, array $options): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
'model' => $options['model'] ?? 'gpt-4o-mini',
|
||||||
|
'messages' => $messages,
|
||||||
|
'temperature' => $options['temperature'] ?? 0.7,
|
||||||
|
'max_tokens' => $options['max_tokens'] ?? null,
|
||||||
|
'top_p' => $options['top_p'] ?? null,
|
||||||
|
'frequency_penalty' => $options['frequency_penalty'] ?? null,
|
||||||
|
'presence_penalty' => $options['presence_penalty'] ?? null,
|
||||||
|
'stop' => $options['stop'] ?? null,
|
||||||
|
'stream' => false,
|
||||||
|
], fn($value) => $value !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getAuthHeaders(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Authorization' => 'Bearer ' . $this->apiKey,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function chatCompletion(array $messages, array $options = []): array
|
||||||
|
{
|
||||||
|
$data = $this->buildRequest($messages, $options);
|
||||||
|
return $this->makeRequest('/chat/completions', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeResponse(array $response): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $response['id'] ?? null,
|
||||||
|
'model' => $response['model'] ?? null,
|
||||||
|
'content' => $response['choices'][0]['message']['content'] ?? '',
|
||||||
|
'role' => $response['choices'][0]['message']['role'] ?? 'assistant',
|
||||||
|
'finish_reason' => $response['choices'][0]['finish_reason'] ?? null,
|
||||||
|
'usage' => [
|
||||||
|
'prompt_tokens' => $response['usage']['prompt_tokens'] ?? 0,
|
||||||
|
'completion_tokens' => $response['usage']['completion_tokens'] ?? 0,
|
||||||
|
'total_tokens' => $response['usage']['total_tokens'] ?? 0,
|
||||||
|
],
|
||||||
|
'raw_response' => $response,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function calculateCost(int $promptTokens, int $completionTokens, string $model): float
|
||||||
|
{
|
||||||
|
$cacheKey = "pricing:openai:{$model}";
|
||||||
|
|
||||||
|
$pricing = Cache::remember($cacheKey, 3600, function () use ($model) {
|
||||||
|
return ModelPricing::where('provider', 'openai')
|
||||||
|
->where('model', $model)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!$pricing) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$promptCost = ($promptTokens / 1_000_000) * $pricing->input_price_per_million;
|
||||||
|
$completionCost = ($completionTokens / 1_000_000) * $pricing->output_price_per_million;
|
||||||
|
|
||||||
|
return round($promptCost + $completionCost, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSupportedModels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'gpt-4o',
|
||||||
|
'gpt-4o-mini',
|
||||||
|
'gpt-4-turbo',
|
||||||
|
'gpt-4',
|
||||||
|
'gpt-3.5-turbo',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
96
laravel-app/app/Services/LLM/RequestLogger.php
Normal file
96
laravel-app/app/Services/LLM/RequestLogger.php
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\LLM;
|
||||||
|
|
||||||
|
use App\Jobs\LogLlmRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class RequestLogger
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Log a successful LLM request
|
||||||
|
*/
|
||||||
|
public function logSuccess(
|
||||||
|
int $userId,
|
||||||
|
string $provider,
|
||||||
|
string $model,
|
||||||
|
array $requestPayload,
|
||||||
|
array $responsePayload,
|
||||||
|
array $costs,
|
||||||
|
int $responseTimeMs,
|
||||||
|
?string $ipAddress = null,
|
||||||
|
?string $userAgent = null
|
||||||
|
): string {
|
||||||
|
$requestId = $this->generateRequestId();
|
||||||
|
|
||||||
|
LogLlmRequest::dispatch(
|
||||||
|
userId: $userId,
|
||||||
|
provider: $provider,
|
||||||
|
model: $model,
|
||||||
|
requestPayload: $requestPayload,
|
||||||
|
responsePayload: $responsePayload,
|
||||||
|
promptTokens: $responsePayload['usage']['prompt_tokens'] ?? 0,
|
||||||
|
completionTokens: $responsePayload['usage']['completion_tokens'] ?? 0,
|
||||||
|
totalTokens: $responsePayload['usage']['total_tokens'] ?? 0,
|
||||||
|
responseTimeMs: $responseTimeMs,
|
||||||
|
promptCost: $costs['prompt_cost'],
|
||||||
|
completionCost: $costs['completion_cost'],
|
||||||
|
totalCost: $costs['total_cost'],
|
||||||
|
status: 'success',
|
||||||
|
errorMessage: null,
|
||||||
|
httpStatus: 200,
|
||||||
|
ipAddress: $ipAddress,
|
||||||
|
userAgent: $userAgent,
|
||||||
|
requestId: $requestId
|
||||||
|
);
|
||||||
|
|
||||||
|
return $requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a failed LLM request
|
||||||
|
*/
|
||||||
|
public function logFailure(
|
||||||
|
int $userId,
|
||||||
|
string $provider,
|
||||||
|
string $model,
|
||||||
|
array $requestPayload,
|
||||||
|
string $errorMessage,
|
||||||
|
int $httpStatus,
|
||||||
|
?string $ipAddress = null,
|
||||||
|
?string $userAgent = null
|
||||||
|
): string {
|
||||||
|
$requestId = $this->generateRequestId();
|
||||||
|
|
||||||
|
LogLlmRequest::dispatch(
|
||||||
|
userId: $userId,
|
||||||
|
provider: $provider,
|
||||||
|
model: $model,
|
||||||
|
requestPayload: $requestPayload,
|
||||||
|
responsePayload: null,
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
responseTimeMs: null,
|
||||||
|
promptCost: 0.0,
|
||||||
|
completionCost: 0.0,
|
||||||
|
totalCost: 0.0,
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: $errorMessage,
|
||||||
|
httpStatus: $httpStatus,
|
||||||
|
ipAddress: $ipAddress,
|
||||||
|
userAgent: $userAgent,
|
||||||
|
requestId: $requestId
|
||||||
|
);
|
||||||
|
|
||||||
|
return $requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate unique request ID
|
||||||
|
*/
|
||||||
|
private function generateRequestId(): string
|
||||||
|
{
|
||||||
|
return 'req_' . Str::random(24);
|
||||||
|
}
|
||||||
|
}
|
||||||
240
laravel-app/app/Services/RateLimit/RateLimitChecker.php
Normal file
240
laravel-app/app/Services/RateLimit/RateLimitChecker.php
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\RateLimit;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\RateLimit;
|
||||||
|
use App\Exceptions\RateLimitExceededException;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class RateLimitChecker
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check if user has exceeded rate limits
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @return bool
|
||||||
|
* @throws RateLimitExceededException
|
||||||
|
*/
|
||||||
|
public function checkRateLimit(User $user): bool
|
||||||
|
{
|
||||||
|
$rateLimit = $this->getOrCreateRateLimit($user);
|
||||||
|
|
||||||
|
// If currently rate limited, check if ban has expired
|
||||||
|
if ($rateLimit->is_rate_limited) {
|
||||||
|
if ($rateLimit->rate_limit_expires_at && now()->greaterThan($rateLimit->rate_limit_expires_at)) {
|
||||||
|
// Rate limit expired, reset
|
||||||
|
$rateLimit->is_rate_limited = false;
|
||||||
|
$rateLimit->rate_limit_expires_at = null;
|
||||||
|
$rateLimit->save();
|
||||||
|
} else {
|
||||||
|
// Still rate limited
|
||||||
|
$expiresIn = $rateLimit->rate_limit_expires_at
|
||||||
|
? $rateLimit->rate_limit_expires_at->diffInSeconds(now())
|
||||||
|
: 60;
|
||||||
|
|
||||||
|
throw new RateLimitExceededException(
|
||||||
|
"Rate limit exceeded. Please try again in " . $expiresIn . " seconds."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset counters if periods have passed
|
||||||
|
$this->resetPeriodsIfNeeded($rateLimit);
|
||||||
|
|
||||||
|
// Check minute limit
|
||||||
|
if ($rateLimit->requests_per_minute > 0) {
|
||||||
|
if ($rateLimit->current_minute_count >= $rateLimit->requests_per_minute) {
|
||||||
|
$this->setRateLimited($rateLimit, 60);
|
||||||
|
|
||||||
|
throw new RateLimitExceededException(
|
||||||
|
"Minute rate limit exceeded ({$rateLimit->requests_per_minute} requests/min). Try again in 60 seconds."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check hour limit
|
||||||
|
if ($rateLimit->requests_per_hour > 0) {
|
||||||
|
if ($rateLimit->current_hour_count >= $rateLimit->requests_per_hour) {
|
||||||
|
$this->setRateLimited($rateLimit, 3600);
|
||||||
|
|
||||||
|
throw new RateLimitExceededException(
|
||||||
|
"Hourly rate limit exceeded ({$rateLimit->requests_per_hour} requests/hour). Try again in 1 hour."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check day limit
|
||||||
|
if ($rateLimit->requests_per_day > 0) {
|
||||||
|
if ($rateLimit->current_day_count >= $rateLimit->requests_per_day) {
|
||||||
|
$secondsUntilMidnight = now()->endOfDay()->diffInSeconds(now());
|
||||||
|
$this->setRateLimited($rateLimit, $secondsUntilMidnight);
|
||||||
|
|
||||||
|
throw new RateLimitExceededException(
|
||||||
|
"Daily rate limit exceeded ({$rateLimit->requests_per_day} requests/day). Try again tomorrow."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment rate limit counters after a request
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function incrementCounter(User $user): void
|
||||||
|
{
|
||||||
|
$rateLimit = $this->getOrCreateRateLimit($user);
|
||||||
|
|
||||||
|
// Reset periods if needed
|
||||||
|
$this->resetPeriodsIfNeeded($rateLimit);
|
||||||
|
|
||||||
|
// Increment counters
|
||||||
|
$rateLimit->current_minute_count++;
|
||||||
|
$rateLimit->current_hour_count++;
|
||||||
|
$rateLimit->current_day_count++;
|
||||||
|
$rateLimit->save();
|
||||||
|
|
||||||
|
Log::debug('Rate limit counter incremented', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'minute_count' => $rateLimit->current_minute_count,
|
||||||
|
'hour_count' => $rateLimit->current_hour_count,
|
||||||
|
'day_count' => $rateLimit->current_day_count
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create rate limit for user
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @return RateLimit
|
||||||
|
*/
|
||||||
|
private function getOrCreateRateLimit(User $user): RateLimit
|
||||||
|
{
|
||||||
|
$rateLimit = $user->rateLimit;
|
||||||
|
|
||||||
|
if (!$rateLimit) {
|
||||||
|
$rateLimit = RateLimit::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'requests_per_minute' => config('llm.rate_limit.requests_per_minute', 60),
|
||||||
|
'requests_per_hour' => config('llm.rate_limit.requests_per_hour', 1000),
|
||||||
|
'requests_per_day' => config('llm.rate_limit.requests_per_day', 10000),
|
||||||
|
'minute_started_at' => now(),
|
||||||
|
'hour_started_at' => now(),
|
||||||
|
'day_started_at' => now()->startOfDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('Rate limit created for user', ['user_id' => $user->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rateLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset rate limit periods if needed
|
||||||
|
*
|
||||||
|
* @param RateLimit $rateLimit
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function resetPeriodsIfNeeded(RateLimit $rateLimit): void
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
$changed = false;
|
||||||
|
|
||||||
|
// Reset minute counter if a minute has passed
|
||||||
|
if ($now->diffInSeconds($rateLimit->minute_started_at) >= 60) {
|
||||||
|
$rateLimit->current_minute_count = 0;
|
||||||
|
$rateLimit->minute_started_at = $now;
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset hour counter if an hour has passed
|
||||||
|
if ($now->diffInSeconds($rateLimit->hour_started_at) >= 3600) {
|
||||||
|
$rateLimit->current_hour_count = 0;
|
||||||
|
$rateLimit->hour_started_at = $now;
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset day counter if a new day has started
|
||||||
|
if ($now->startOfDay()->greaterThan($rateLimit->day_started_at)) {
|
||||||
|
$rateLimit->current_day_count = 0;
|
||||||
|
$rateLimit->day_started_at = $now->startOfDay();
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changed) {
|
||||||
|
$rateLimit->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set user as rate limited
|
||||||
|
*
|
||||||
|
* @param RateLimit $rateLimit
|
||||||
|
* @param int $durationSeconds
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function setRateLimited(RateLimit $rateLimit, int $durationSeconds): void
|
||||||
|
{
|
||||||
|
$rateLimit->is_rate_limited = true;
|
||||||
|
$rateLimit->rate_limit_expires_at = now()->addSeconds($durationSeconds);
|
||||||
|
$rateLimit->save();
|
||||||
|
|
||||||
|
Log::warning('User rate limited', [
|
||||||
|
'user_id' => $rateLimit->user_id,
|
||||||
|
'expires_at' => $rateLimit->rate_limit_expires_at,
|
||||||
|
'duration_seconds' => $durationSeconds
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rate limit status for user
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getRateLimitStatus(User $user): array
|
||||||
|
{
|
||||||
|
$rateLimit = $this->getOrCreateRateLimit($user);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'requests_per_minute' => $rateLimit->requests_per_minute,
|
||||||
|
'requests_per_hour' => $rateLimit->requests_per_hour,
|
||||||
|
'requests_per_day' => $rateLimit->requests_per_day,
|
||||||
|
'current_minute_count' => $rateLimit->current_minute_count,
|
||||||
|
'current_hour_count' => $rateLimit->current_hour_count,
|
||||||
|
'current_day_count' => $rateLimit->current_day_count,
|
||||||
|
'minute_remaining' => max(0, $rateLimit->requests_per_minute - $rateLimit->current_minute_count),
|
||||||
|
'hour_remaining' => max(0, $rateLimit->requests_per_hour - $rateLimit->current_hour_count),
|
||||||
|
'day_remaining' => max(0, $rateLimit->requests_per_day - $rateLimit->current_day_count),
|
||||||
|
'is_rate_limited' => $rateLimit->is_rate_limited,
|
||||||
|
'rate_limit_expires_at' => $rateLimit->rate_limit_expires_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually reset rate limit for user (admin function)
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function resetRateLimit(User $user): void
|
||||||
|
{
|
||||||
|
$rateLimit = $this->getOrCreateRateLimit($user);
|
||||||
|
|
||||||
|
$rateLimit->current_minute_count = 0;
|
||||||
|
$rateLimit->current_hour_count = 0;
|
||||||
|
$rateLimit->current_day_count = 0;
|
||||||
|
$rateLimit->is_rate_limited = false;
|
||||||
|
$rateLimit->rate_limit_expires_at = null;
|
||||||
|
$rateLimit->minute_started_at = now();
|
||||||
|
$rateLimit->hour_started_at = now();
|
||||||
|
$rateLimit->day_started_at = now()->startOfDay();
|
||||||
|
$rateLimit->save();
|
||||||
|
|
||||||
|
Log::info('Rate limit manually reset', ['user_id' => $user->id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\UsageLog;
|
use App\Models\LlmRequest;
|
||||||
use App\Models\GatewayUser;
|
use App\Models\User;
|
||||||
|
use App\Models\UserProviderCredential;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class StatisticsService
|
class StatisticsService
|
||||||
@@ -11,27 +12,34 @@ class StatisticsService
|
|||||||
/**
|
/**
|
||||||
* Get dashboard overview statistics
|
* Get dashboard overview statistics
|
||||||
*/
|
*/
|
||||||
public function getDashboardStats()
|
public function getDashboardStats(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'total_users' => GatewayUser::count(),
|
'total_users' => User::count(),
|
||||||
'active_users' => GatewayUser::active()->count(),
|
'active_credentials' => UserProviderCredential::where('is_active', true)->count(),
|
||||||
'blocked_users' => GatewayUser::blocked()->count(),
|
'total_requests_today' => LlmRequest::whereDate('created_at', today())->count(),
|
||||||
'total_requests_today' => UsageLog::today()->count(),
|
'total_spend_today' => LlmRequest::whereDate('created_at', today())->sum('total_cost') ?? 0,
|
||||||
'total_spend_today' => UsageLog::today()->sum('cost') ?? 0,
|
'total_tokens_today' => LlmRequest::whereDate('created_at', today())->sum('total_tokens') ?? 0,
|
||||||
'total_tokens_today' => UsageLog::today()->sum('total_tokens') ?? 0,
|
'total_spend_month' => LlmRequest::whereMonth('created_at', now()->month)
|
||||||
'total_spend_month' => UsageLog::whereMonth('timestamp', now()->month)->sum('cost') ?? 0,
|
->whereYear('created_at', now()->year)
|
||||||
'total_requests_month' => UsageLog::whereMonth('timestamp', now()->month)->count(),
|
->sum('total_cost') ?? 0,
|
||||||
|
'total_requests_month' => LlmRequest::whereMonth('created_at', now()->month)
|
||||||
|
->whereYear('created_at', now()->year)
|
||||||
|
->count(),
|
||||||
|
'avg_cost_per_request' => LlmRequest::whereMonth('created_at', now()->month)
|
||||||
|
->whereYear('created_at', now()->year)
|
||||||
|
->avg('total_cost') ?? 0,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get usage breakdown by provider
|
* Get usage breakdown by provider
|
||||||
*/
|
*/
|
||||||
public function getUsageByProvider($days = 30)
|
public function getUsageByProvider(int $days = 30)
|
||||||
{
|
{
|
||||||
return UsageLog::selectRaw('provider, COUNT(*) as count, SUM(cost) as total_cost')
|
return LlmRequest::selectRaw('provider, COUNT(*) as count, SUM(total_cost) as total_cost, SUM(total_tokens) as total_tokens')
|
||||||
->where('timestamp', '>=', now()->subDays($days))
|
->where('created_at', '>=', now()->subDays($days))
|
||||||
|
->where('status', 'success')
|
||||||
->groupBy('provider')
|
->groupBy('provider')
|
||||||
->orderByDesc('count')
|
->orderByDesc('count')
|
||||||
->get();
|
->get();
|
||||||
@@ -40,11 +48,12 @@ class StatisticsService
|
|||||||
/**
|
/**
|
||||||
* Get usage breakdown by model
|
* Get usage breakdown by model
|
||||||
*/
|
*/
|
||||||
public function getUsageByModel($days = 30)
|
public function getUsageByModel(int $days = 30)
|
||||||
{
|
{
|
||||||
return UsageLog::selectRaw('model, COUNT(*) as count, SUM(total_tokens) as tokens, SUM(cost) as total_cost')
|
return LlmRequest::selectRaw('model, provider, COUNT(*) as count, SUM(total_tokens) as tokens, SUM(total_cost) as total_cost')
|
||||||
->where('timestamp', '>=', now()->subDays($days))
|
->where('created_at', '>=', now()->subDays($days))
|
||||||
->groupBy('model')
|
->where('status', 'success')
|
||||||
|
->groupBy('model', 'provider')
|
||||||
->orderByDesc('count')
|
->orderByDesc('count')
|
||||||
->limit(10)
|
->limit(10)
|
||||||
->get();
|
->get();
|
||||||
@@ -53,10 +62,11 @@ class StatisticsService
|
|||||||
/**
|
/**
|
||||||
* Get daily usage chart data
|
* Get daily usage chart data
|
||||||
*/
|
*/
|
||||||
public function getDailyUsageChart($days = 30)
|
public function getDailyUsageChart(int $days = 30)
|
||||||
{
|
{
|
||||||
return UsageLog::selectRaw('DATE(timestamp) as date, COUNT(*) as requests, SUM(cost) as cost, SUM(total_tokens) as tokens')
|
return LlmRequest::selectRaw('DATE(created_at) as date, COUNT(*) as requests, SUM(total_cost) as cost, SUM(total_tokens) as tokens')
|
||||||
->where('timestamp', '>=', now()->subDays($days))
|
->where('created_at', '>=', now()->subDays($days))
|
||||||
|
->where('status', 'success')
|
||||||
->groupBy('date')
|
->groupBy('date')
|
||||||
->orderBy('date')
|
->orderBy('date')
|
||||||
->get();
|
->get();
|
||||||
@@ -65,11 +75,13 @@ class StatisticsService
|
|||||||
/**
|
/**
|
||||||
* Get top users by spend
|
* Get top users by spend
|
||||||
*/
|
*/
|
||||||
public function getTopUsers($limit = 10)
|
public function getTopUsers(int $limit = 10)
|
||||||
{
|
{
|
||||||
return GatewayUser::withCount('usageLogs')
|
return User::select('users.*')
|
||||||
->withSum('usageLogs', 'cost')
|
->withCount('llmRequests')
|
||||||
->orderByDesc('usage_logs_sum_cost')
|
->withSum('llmRequests as total_cost', 'total_cost')
|
||||||
|
->withSum('llmRequests as total_tokens', 'total_tokens')
|
||||||
|
->orderByDesc('total_cost')
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
@@ -77,10 +89,10 @@ class StatisticsService
|
|||||||
/**
|
/**
|
||||||
* Get recent activity
|
* Get recent activity
|
||||||
*/
|
*/
|
||||||
public function getRecentActivity($limit = 20)
|
public function getRecentActivity(int $limit = 20)
|
||||||
{
|
{
|
||||||
return UsageLog::with(['gatewayUser', 'apiKey'])
|
return LlmRequest::with('user')
|
||||||
->orderByDesc('timestamp')
|
->orderByDesc('created_at')
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
@@ -88,18 +100,82 @@ class StatisticsService
|
|||||||
/**
|
/**
|
||||||
* Get user statistics
|
* Get user statistics
|
||||||
*/
|
*/
|
||||||
public function getUserStatistics($userId, $days = 30)
|
public function getUserStatistics(int $userId, int $days = 30)
|
||||||
{
|
{
|
||||||
return UsageLog::where('user_id', $userId)
|
return LlmRequest::where('user_id', $userId)
|
||||||
->where('timestamp', '>=', now()->subDays($days))
|
->where('created_at', '>=', now()->subDays($days))
|
||||||
|
->where('status', 'success')
|
||||||
->selectRaw('
|
->selectRaw('
|
||||||
COUNT(*) as total_requests,
|
COUNT(*) as total_requests,
|
||||||
SUM(prompt_tokens) as total_prompt_tokens,
|
SUM(prompt_tokens) as total_prompt_tokens,
|
||||||
SUM(completion_tokens) as total_completion_tokens,
|
SUM(completion_tokens) as total_completion_tokens,
|
||||||
SUM(total_tokens) as total_tokens,
|
SUM(total_tokens) as total_tokens,
|
||||||
SUM(cost) as total_cost,
|
SUM(total_cost) as total_cost,
|
||||||
AVG(total_tokens) as avg_tokens_per_request
|
AVG(total_tokens) as avg_tokens_per_request,
|
||||||
|
AVG(total_cost) as avg_cost_per_request,
|
||||||
|
AVG(response_time_ms) as avg_response_time_ms
|
||||||
')
|
')
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get provider usage over time
|
||||||
|
*/
|
||||||
|
public function getProviderUsageOverTime(int $days = 30)
|
||||||
|
{
|
||||||
|
return LlmRequest::selectRaw('DATE(created_at) as date, provider, COUNT(*) as count, SUM(total_cost) as cost')
|
||||||
|
->where('created_at', '>=', now()->subDays($days))
|
||||||
|
->where('status', 'success')
|
||||||
|
->groupBy('date', 'provider')
|
||||||
|
->orderBy('date')
|
||||||
|
->get()
|
||||||
|
->groupBy('provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cost trends
|
||||||
|
*/
|
||||||
|
public function getCostTrends(int $days = 30)
|
||||||
|
{
|
||||||
|
$data = LlmRequest::selectRaw('
|
||||||
|
DATE(created_at) as date,
|
||||||
|
SUM(total_cost) as daily_cost,
|
||||||
|
AVG(total_cost) as avg_request_cost,
|
||||||
|
COUNT(*) as request_count
|
||||||
|
')
|
||||||
|
->where('created_at', '>=', now()->subDays($days))
|
||||||
|
->where('status', 'success')
|
||||||
|
->groupBy('date')
|
||||||
|
->orderBy('date')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'daily_data' => $data,
|
||||||
|
'total_cost' => $data->sum('daily_cost'),
|
||||||
|
'avg_daily_cost' => $data->avg('daily_cost'),
|
||||||
|
'total_requests' => $data->sum('request_count'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get error statistics
|
||||||
|
*/
|
||||||
|
public function getErrorStats(int $days = 30)
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'total_errors' => LlmRequest::where('created_at', '>=', now()->subDays($days))
|
||||||
|
->where('status', '!=', 'success')
|
||||||
|
->count(),
|
||||||
|
'errors_by_status' => LlmRequest::selectRaw('status, COUNT(*) as count')
|
||||||
|
->where('created_at', '>=', now()->subDays($days))
|
||||||
|
->where('status', '!=', 'success')
|
||||||
|
->groupBy('status')
|
||||||
|
->get(),
|
||||||
|
'errors_by_provider' => LlmRequest::selectRaw('provider, COUNT(*) as count')
|
||||||
|
->where('created_at', '>=', now()->subDays($days))
|
||||||
|
->where('status', '!=', 'success')
|
||||||
|
->groupBy('provider')
|
||||||
|
->get(),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ use Illuminate\Foundation\Configuration\Middleware;
|
|||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
$middleware->alias([
|
||||||
|
'checkbudget' => \App\Http\Middleware\CheckBudget::class,
|
||||||
|
'checkratelimit' => \App\Http\Middleware\CheckRateLimit::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ return [
|
|||||||
|
|
||||||
'defaults' => [
|
'defaults' => [
|
||||||
'guard' => env('AUTH_GUARD', 'web'),
|
'guard' => env('AUTH_GUARD', 'web'),
|
||||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'admins'),
|
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -38,7 +38,7 @@ return [
|
|||||||
'guards' => [
|
'guards' => [
|
||||||
'web' => [
|
'web' => [
|
||||||
'driver' => 'session',
|
'driver' => 'session',
|
||||||
'provider' => 'admins',
|
'provider' => 'users',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
90
laravel-app/config/llm.php
Normal file
90
laravel-app/config/llm.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Budget Settings
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These values are used when creating new user budgets
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'default_monthly_budget' => env('LLM_DEFAULT_MONTHLY_BUDGET', 100.00),
|
||||||
|
'default_daily_budget' => env('LLM_DEFAULT_DAILY_BUDGET', 10.00),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Rate Limiting Settings
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Default rate limits for API requests per user
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'rate_limit' => [
|
||||||
|
'requests_per_minute' => env('LLM_RATE_LIMIT_PER_MINUTE', 60),
|
||||||
|
'requests_per_hour' => env('LLM_RATE_LIMIT_PER_HOUR', 1000),
|
||||||
|
'requests_per_day' => env('LLM_RATE_LIMIT_PER_DAY', 10000),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Supported Providers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| List of AI providers supported by the gateway
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'providers' => [
|
||||||
|
'openai' => [
|
||||||
|
'name' => 'OpenAI',
|
||||||
|
'api_url' => 'https://api.openai.com/v1',
|
||||||
|
'models' => ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'],
|
||||||
|
],
|
||||||
|
'anthropic' => [
|
||||||
|
'name' => 'Anthropic (Claude)',
|
||||||
|
'api_url' => 'https://api.anthropic.com/v1',
|
||||||
|
'models' => ['claude-opus-4', 'claude-sonnet-4', 'claude-haiku-4'],
|
||||||
|
],
|
||||||
|
'mistral' => [
|
||||||
|
'name' => 'Mistral AI',
|
||||||
|
'api_url' => 'https://api.mistral.ai/v1',
|
||||||
|
'models' => ['mistral-large', 'mistral-medium', 'mistral-small'],
|
||||||
|
],
|
||||||
|
'gemini' => [
|
||||||
|
'name' => 'Google Gemini',
|
||||||
|
'api_url' => 'https://generativelanguage.googleapis.com/v1beta',
|
||||||
|
'models' => ['gemini-pro', 'gemini-pro-vision'],
|
||||||
|
],
|
||||||
|
'deepseek' => [
|
||||||
|
'name' => 'DeepSeek',
|
||||||
|
'api_url' => 'https://api.deepseek.com/v1',
|
||||||
|
'models' => ['deepseek-chat', 'deepseek-coder'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Logging Settings
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configuration for request logging
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'logging' => [
|
||||||
|
'enabled' => env('LLM_LOGGING_ENABLED', true),
|
||||||
|
'queue' => env('LLM_LOGGING_QUEUE', true),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Alert Settings
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Budget alert threshold and notification settings
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'alerts' => [
|
||||||
|
'budget_threshold_percentage' => env('LLM_ALERT_THRESHOLD', 80),
|
||||||
|
'email_enabled' => env('LLM_ALERT_EMAIL_ENABLED', true),
|
||||||
|
],
|
||||||
|
];
|
||||||
31
laravel-app/create-admin.php
Normal file
31
laravel-app/create-admin.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Create Admin User Script
|
||||||
|
require __DIR__.'/vendor/autoload.php';
|
||||||
|
|
||||||
|
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||||
|
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
||||||
|
$kernel->bootstrap();
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$user = User::firstOrCreate(
|
||||||
|
['email' => 'admin@example.com'],
|
||||||
|
[
|
||||||
|
'name' => 'Admin User',
|
||||||
|
'password' => Hash::make('password'),
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "\n✅ User created successfully!\n";
|
||||||
|
echo "📧 Email: admin@example.com\n";
|
||||||
|
echo "🔑 Password: password\n";
|
||||||
|
echo "👤 Name: {$user->name}\n";
|
||||||
|
echo "🆔 ID: {$user->id}\n\n";
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo "\n❌ Error: " . $e->getMessage() . "\n\n";
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('admins', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->string('name');
|
|
||||||
$table->string('email')->unique();
|
|
||||||
$table->timestamp('email_verified_at')->nullable();
|
|
||||||
$table->string('password');
|
|
||||||
$table->rememberToken();
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('admins');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
// Password Reset Tokens für Admins
|
|
||||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
|
||||||
$table->string('email')->primary();
|
|
||||||
$table->string('token');
|
|
||||||
$table->timestamp('created_at')->nullable();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sessions Tabelle (für admins)
|
|
||||||
Schema::create('sessions', function (Blueprint $table) {
|
|
||||||
$table->string('id')->primary();
|
|
||||||
$table->foreignId('user_id')->nullable()->index(); // referenziert admins.id
|
|
||||||
$table->string('ip_address', 45)->nullable();
|
|
||||||
$table->text('user_agent')->nullable();
|
|
||||||
$table->longText('payload');
|
|
||||||
$table->integer('last_activity')->index();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('password_reset_tokens');
|
|
||||||
Schema::dropIfExists('sessions');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_provider_credentials', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained('users')->onDelete('cascade');
|
||||||
|
$table->string('provider', 50)->comment('openai, anthropic, mistral, gemini, deepseek');
|
||||||
|
$table->text('api_key')->comment('Encrypted API key');
|
||||||
|
$table->string('organization_id', 255)->nullable()->comment('Optional organization ID');
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamp('last_used_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['user_id', 'provider']);
|
||||||
|
$table->index('user_id');
|
||||||
|
$table->index('provider');
|
||||||
|
$table->index('is_active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_provider_credentials');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('llm_requests', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained('users')->onDelete('cascade');
|
||||||
|
$table->string('provider', 50);
|
||||||
|
$table->string('model', 100);
|
||||||
|
|
||||||
|
// Request Details
|
||||||
|
$table->json('request_payload')->comment('Original request');
|
||||||
|
$table->json('response_payload')->nullable()->comment('Provider response');
|
||||||
|
|
||||||
|
// Tokens & Timing
|
||||||
|
$table->unsignedInteger('prompt_tokens')->default(0);
|
||||||
|
$table->unsignedInteger('completion_tokens')->default(0);
|
||||||
|
$table->unsignedInteger('total_tokens')->default(0);
|
||||||
|
$table->unsignedInteger('response_time_ms')->nullable()->comment('Response time in milliseconds');
|
||||||
|
|
||||||
|
// Cost Calculation
|
||||||
|
$table->decimal('prompt_cost', 10, 6)->default(0)->comment('Cost in USD');
|
||||||
|
$table->decimal('completion_cost', 10, 6)->default(0)->comment('Cost in USD');
|
||||||
|
$table->decimal('total_cost', 10, 6)->default(0)->comment('Cost in USD');
|
||||||
|
|
||||||
|
// Status & Error Handling
|
||||||
|
$table->string('status', 20)->default('pending')->comment('pending, success, failed, rate_limited');
|
||||||
|
$table->text('error_message')->nullable();
|
||||||
|
$table->unsignedInteger('http_status')->nullable();
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->string('user_agent', 500)->nullable();
|
||||||
|
$table->string('request_id', 100)->nullable()->comment('Unique request identifier');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('user_id');
|
||||||
|
$table->index(['provider', 'model']);
|
||||||
|
$table->index('status');
|
||||||
|
$table->index('created_at');
|
||||||
|
$table->index('request_id');
|
||||||
|
$table->index(['user_id', 'created_at', 'total_cost']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('llm_requests');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('model_pricing', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('provider', 50);
|
||||||
|
$table->string('model', 100);
|
||||||
|
|
||||||
|
// Pricing (per 1M tokens)
|
||||||
|
$table->decimal('input_price_per_million', 10, 4)->comment('Cost per 1M input tokens in USD');
|
||||||
|
$table->decimal('output_price_per_million', 10, 4)->comment('Cost per 1M output tokens in USD');
|
||||||
|
|
||||||
|
// Model Information
|
||||||
|
$table->unsignedInteger('context_window')->nullable()->comment('Maximum context size');
|
||||||
|
$table->unsignedInteger('max_output_tokens')->nullable()->comment('Maximum output tokens');
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->date('effective_from')->default(DB::raw('CURRENT_DATE'));
|
||||||
|
$table->date('effective_until')->nullable();
|
||||||
|
$table->text('notes')->nullable();
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['provider', 'model', 'effective_from']);
|
||||||
|
$table->index('provider');
|
||||||
|
$table->index('model');
|
||||||
|
$table->index('is_active');
|
||||||
|
$table->index(['effective_from', 'effective_until']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('model_pricing');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_budgets', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained('users')->onDelete('cascade');
|
||||||
|
|
||||||
|
// Budget Configuration
|
||||||
|
$table->decimal('monthly_limit', 10, 2)->default(0)->comment('Monthly spending limit in USD');
|
||||||
|
$table->decimal('daily_limit', 10, 2)->nullable()->comment('Optional daily limit');
|
||||||
|
|
||||||
|
// Current Period Tracking
|
||||||
|
$table->decimal('current_month_spending', 10, 2)->default(0);
|
||||||
|
$table->decimal('current_day_spending', 10, 2)->default(0);
|
||||||
|
|
||||||
|
// Period Timestamps
|
||||||
|
$table->date('month_started_at');
|
||||||
|
$table->date('day_started_at');
|
||||||
|
|
||||||
|
// Alert Thresholds
|
||||||
|
$table->unsignedInteger('alert_threshold_percentage')->default(80)->comment('Alert at X% of limit');
|
||||||
|
$table->timestamp('last_alert_sent_at')->nullable();
|
||||||
|
|
||||||
|
// Status
|
||||||
|
$table->boolean('is_budget_exceeded')->default(false);
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique('user_id');
|
||||||
|
$table->index('is_active');
|
||||||
|
$table->index('is_budget_exceeded');
|
||||||
|
$table->index('current_month_spending');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_budgets');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('rate_limits', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained('users')->onDelete('cascade');
|
||||||
|
|
||||||
|
// Rate Limit Configuration
|
||||||
|
$table->unsignedInteger('requests_per_minute')->default(60);
|
||||||
|
$table->unsignedInteger('requests_per_hour')->default(1000);
|
||||||
|
$table->unsignedInteger('requests_per_day')->default(10000);
|
||||||
|
|
||||||
|
// Current Period Counters
|
||||||
|
$table->unsignedInteger('current_minute_count')->default(0);
|
||||||
|
$table->unsignedInteger('current_hour_count')->default(0);
|
||||||
|
$table->unsignedInteger('current_day_count')->default(0);
|
||||||
|
|
||||||
|
// Period Timestamps
|
||||||
|
$table->timestamp('minute_started_at')->useCurrent();
|
||||||
|
$table->timestamp('hour_started_at')->useCurrent();
|
||||||
|
$table->timestamp('day_started_at')->useCurrent();
|
||||||
|
|
||||||
|
// Status
|
||||||
|
$table->boolean('is_rate_limited')->default(false);
|
||||||
|
$table->timestamp('rate_limit_expires_at')->nullable();
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique('user_id');
|
||||||
|
$table->index('is_rate_limited');
|
||||||
|
$table->index('rate_limit_expires_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('rate_limits');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
DB::table('model_pricing')->insert([
|
||||||
|
// Mistral AI Models
|
||||||
|
[
|
||||||
|
'provider' => 'mistral',
|
||||||
|
'model' => 'mistral-large-latest',
|
||||||
|
'input_price_per_million' => 2.00,
|
||||||
|
'output_price_per_million' => 6.00,
|
||||||
|
'context_window' => 128000,
|
||||||
|
'max_output_tokens' => 4096,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'provider' => 'mistral',
|
||||||
|
'model' => 'mistral-medium-latest',
|
||||||
|
'input_price_per_million' => 2.70,
|
||||||
|
'output_price_per_million' => 8.10,
|
||||||
|
'context_window' => 32000,
|
||||||
|
'max_output_tokens' => 4096,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'provider' => 'mistral',
|
||||||
|
'model' => 'mistral-small-latest',
|
||||||
|
'input_price_per_million' => 0.20,
|
||||||
|
'output_price_per_million' => 0.60,
|
||||||
|
'context_window' => 32000,
|
||||||
|
'max_output_tokens' => 4096,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'provider' => 'mistral',
|
||||||
|
'model' => 'open-mistral-7b',
|
||||||
|
'input_price_per_million' => 0.25,
|
||||||
|
'output_price_per_million' => 0.25,
|
||||||
|
'context_window' => 32000,
|
||||||
|
'max_output_tokens' => 4096,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'provider' => 'mistral',
|
||||||
|
'model' => 'open-mixtral-8x7b',
|
||||||
|
'input_price_per_million' => 0.70,
|
||||||
|
'output_price_per_million' => 0.70,
|
||||||
|
'context_window' => 32000,
|
||||||
|
'max_output_tokens' => 4096,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Google Gemini Models
|
||||||
|
[
|
||||||
|
'provider' => 'gemini',
|
||||||
|
'model' => 'gemini-pro',
|
||||||
|
'input_price_per_million' => 0.50,
|
||||||
|
'output_price_per_million' => 1.50,
|
||||||
|
'context_window' => 32760,
|
||||||
|
'max_output_tokens' => 2048,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'provider' => 'gemini',
|
||||||
|
'model' => 'gemini-1.5-pro',
|
||||||
|
'input_price_per_million' => 3.50,
|
||||||
|
'output_price_per_million' => 10.50,
|
||||||
|
'context_window' => 2097152,
|
||||||
|
'max_output_tokens' => 8192,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'provider' => 'gemini',
|
||||||
|
'model' => 'gemini-1.5-flash',
|
||||||
|
'input_price_per_million' => 0.35,
|
||||||
|
'output_price_per_million' => 1.05,
|
||||||
|
'context_window' => 1048576,
|
||||||
|
'max_output_tokens' => 8192,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
|
||||||
|
// DeepSeek Models
|
||||||
|
[
|
||||||
|
'provider' => 'deepseek',
|
||||||
|
'model' => 'deepseek-chat',
|
||||||
|
'input_price_per_million' => 0.14,
|
||||||
|
'output_price_per_million' => 0.28,
|
||||||
|
'context_window' => 64000,
|
||||||
|
'max_output_tokens' => 4096,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'provider' => 'deepseek',
|
||||||
|
'model' => 'deepseek-coder',
|
||||||
|
'input_price_per_million' => 0.14,
|
||||||
|
'output_price_per_million' => 0.28,
|
||||||
|
'context_window' => 128000,
|
||||||
|
'max_output_tokens' => 4096,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'provider' => 'deepseek',
|
||||||
|
'model' => 'deepseek-reasoner',
|
||||||
|
'input_price_per_million' => 0.55,
|
||||||
|
'output_price_per_million' => 2.19,
|
||||||
|
'context_window' => 64000,
|
||||||
|
'max_output_tokens' => 8192,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::table('model_pricing')
|
||||||
|
->whereIn('provider', ['mistral', 'gemini', 'deepseek'])
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('gateway_users', function (Blueprint $table) {
|
||||||
|
$table->string('user_id')->primary();
|
||||||
|
$table->string('alias')->nullable();
|
||||||
|
$table->string('budget_id')->nullable();
|
||||||
|
$table->decimal('spend', 10, 2)->default(0);
|
||||||
|
$table->boolean('blocked')->default(false);
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('blocked');
|
||||||
|
$table->index('created_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('gateway_users');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('budgets', function (Blueprint $table) {
|
||||||
|
$table->string('budget_id')->primary();
|
||||||
|
$table->string('name');
|
||||||
|
$table->decimal('monthly_limit', 10, 2)->nullable();
|
||||||
|
$table->decimal('daily_limit', 10, 2)->nullable();
|
||||||
|
$table->string('created_by')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('budgets');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('api_keys', function (Blueprint $table) {
|
||||||
|
$table->string('token')->primary();
|
||||||
|
$table->string('user_id');
|
||||||
|
$table->string('key_alias')->nullable();
|
||||||
|
$table->string('key_name')->nullable();
|
||||||
|
$table->json('permissions')->nullable();
|
||||||
|
$table->json('models')->nullable();
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamp('expires')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('user_id');
|
||||||
|
$table->index('expires');
|
||||||
|
|
||||||
|
$table->foreign('user_id')
|
||||||
|
->references('user_id')
|
||||||
|
->on('gateway_users')
|
||||||
|
->onDelete('cascade');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('api_keys');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('usage_logs', function (Blueprint $table) {
|
||||||
|
$table->string('request_id')->primary();
|
||||||
|
$table->string('user_id');
|
||||||
|
$table->string('api_key');
|
||||||
|
$table->string('model');
|
||||||
|
$table->string('provider')->nullable();
|
||||||
|
$table->integer('prompt_tokens')->default(0);
|
||||||
|
$table->integer('completion_tokens')->default(0);
|
||||||
|
$table->integer('total_tokens')->default(0);
|
||||||
|
$table->decimal('cost', 10, 6)->default(0);
|
||||||
|
$table->timestamp('timestamp')->useCurrent();
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
|
||||||
|
$table->index('user_id');
|
||||||
|
$table->index('api_key');
|
||||||
|
$table->index('model');
|
||||||
|
$table->index('timestamp');
|
||||||
|
|
||||||
|
$table->foreign('user_id')
|
||||||
|
->references('user_id')
|
||||||
|
->on('gateway_users')
|
||||||
|
->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->foreign('api_key')
|
||||||
|
->references('token')
|
||||||
|
->on('api_keys')
|
||||||
|
->onDelete('cascade');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('usage_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('usage_logs', function (Blueprint $table) {
|
||||||
|
$table->string('status')->default('success')->after('cost');
|
||||||
|
$table->string('endpoint')->nullable()->after('provider');
|
||||||
|
$table->text('error_message')->nullable()->after('status');
|
||||||
|
|
||||||
|
// Add index for status for better query performance
|
||||||
|
$table->index('status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('usage_logs', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['status']);
|
||||||
|
$table->dropColumn(['status', 'endpoint', 'error_message']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
29
laravel-app/database/seeders/AdminUserSeeder.php
Normal file
29
laravel-app/database/seeders/AdminUserSeeder.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class AdminUserSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
User::firstOrCreate(
|
||||||
|
['email' => 'admin@example.com'],
|
||||||
|
[
|
||||||
|
'name' => 'Admin User',
|
||||||
|
'password' => Hash::make('password'),
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->command->info('Admin user created successfully!');
|
||||||
|
$this->command->info('Email: admin@example.com');
|
||||||
|
$this->command->info('Password: password');
|
||||||
|
}
|
||||||
|
}
|
||||||
158
laravel-app/database/seeders/ModelPricingSeeder.php
Normal file
158
laravel-app/database/seeders/ModelPricingSeeder.php
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class ModelPricingSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$now = Carbon::now();
|
||||||
|
|
||||||
|
$pricingData = [
|
||||||
|
// OpenAI Models
|
||||||
|
[
|
||||||
|
'provider' => 'openai',
|
||||||
|
'model' => 'gpt-4o',
|
||||||
|
'input_price_per_million' => 2.50,
|
||||||
|
'output_price_per_million' => 10.00,
|
||||||
|
'context_window' => 128000,
|
||||||
|
'max_output_tokens' => 16384,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now->toDateString(),
|
||||||
|
'notes' => 'GPT-4 Omni - Most capable model',
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'provider' => 'openai',
|
||||||
|
'model' => 'gpt-4o-mini',
|
||||||
|
'input_price_per_million' => 0.15,
|
||||||
|
'output_price_per_million' => 0.60,
|
||||||
|
'context_window' => 128000,
|
||||||
|
'max_output_tokens' => 16384,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now->toDateString(),
|
||||||
|
'notes' => 'Cost-efficient model for simple tasks',
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'provider' => 'openai',
|
||||||
|
'model' => 'gpt-4-turbo',
|
||||||
|
'input_price_per_million' => 10.00,
|
||||||
|
'output_price_per_million' => 30.00,
|
||||||
|
'context_window' => 128000,
|
||||||
|
'max_output_tokens' => 4096,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now->toDateString(),
|
||||||
|
'notes' => 'GPT-4 Turbo with vision capabilities',
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'provider' => 'openai',
|
||||||
|
'model' => 'gpt-3.5-turbo',
|
||||||
|
'input_price_per_million' => 0.50,
|
||||||
|
'output_price_per_million' => 1.50,
|
||||||
|
'context_window' => 16385,
|
||||||
|
'max_output_tokens' => 4096,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now->toDateString(),
|
||||||
|
'notes' => 'Fast and affordable legacy model',
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Anthropic Models
|
||||||
|
[
|
||||||
|
'provider' => 'anthropic',
|
||||||
|
'model' => 'claude-opus-4',
|
||||||
|
'input_price_per_million' => 15.00,
|
||||||
|
'output_price_per_million' => 75.00,
|
||||||
|
'context_window' => 200000,
|
||||||
|
'max_output_tokens' => 4096,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now->toDateString(),
|
||||||
|
'notes' => 'Most capable Claude model',
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'provider' => 'anthropic',
|
||||||
|
'model' => 'claude-sonnet-4',
|
||||||
|
'input_price_per_million' => 3.00,
|
||||||
|
'output_price_per_million' => 15.00,
|
||||||
|
'context_window' => 200000,
|
||||||
|
'max_output_tokens' => 8192,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now->toDateString(),
|
||||||
|
'notes' => 'Balanced performance and cost',
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'provider' => 'anthropic',
|
||||||
|
'model' => 'claude-haiku-4',
|
||||||
|
'input_price_per_million' => 0.25,
|
||||||
|
'output_price_per_million' => 1.25,
|
||||||
|
'context_window' => 200000,
|
||||||
|
'max_output_tokens' => 4096,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now->toDateString(),
|
||||||
|
'notes' => 'Fast and cost-effective',
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Mistral AI Models
|
||||||
|
[
|
||||||
|
'provider' => 'mistral',
|
||||||
|
'model' => 'mistral-large',
|
||||||
|
'input_price_per_million' => 2.00,
|
||||||
|
'output_price_per_million' => 6.00,
|
||||||
|
'context_window' => 128000,
|
||||||
|
'max_output_tokens' => 4096,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now->toDateString(),
|
||||||
|
'notes' => 'Most capable Mistral model',
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'provider' => 'mistral',
|
||||||
|
'model' => 'mistral-medium',
|
||||||
|
'input_price_per_million' => 2.70,
|
||||||
|
'output_price_per_million' => 8.10,
|
||||||
|
'context_window' => 32000,
|
||||||
|
'max_output_tokens' => 4096,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now->toDateString(),
|
||||||
|
'notes' => 'Balanced Mistral model',
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'provider' => 'mistral',
|
||||||
|
'model' => 'mistral-small',
|
||||||
|
'input_price_per_million' => 0.20,
|
||||||
|
'output_price_per_million' => 0.60,
|
||||||
|
'context_window' => 32000,
|
||||||
|
'max_output_tokens' => 4096,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => $now->toDateString(),
|
||||||
|
'notes' => 'Cost-effective Mistral model',
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
DB::table('model_pricing')->insert($pricingData);
|
||||||
|
|
||||||
|
$this->command->info('Model pricing data seeded successfully!');
|
||||||
|
$this->command->info('Total models: ' . count($pricingData));
|
||||||
|
}
|
||||||
|
}
|
||||||
198
laravel-app/resources/views/admin/credentials/create.blade.php
Normal file
198
laravel-app/resources/views/admin/credentials/create.blade.php
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||||
|
{{ __('Add Provider Credentials') }}
|
||||||
|
</h2>
|
||||||
|
<a href="{{ route('admin.credentials.index') }}" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Back to List
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="py-12">
|
||||||
|
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
|
||||||
|
@if(session('error'))
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||||
|
{{ session('error') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<form method="POST" action="{{ route('admin.credentials.store') }}" class="space-y-6">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<!-- User Selection -->
|
||||||
|
<div>
|
||||||
|
<label for="user_id" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
User <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="user_id"
|
||||||
|
id="user_id"
|
||||||
|
required
|
||||||
|
class="w-full rounded-md border-gray-300 @error('user_id') border-red-500 @enderror"
|
||||||
|
>
|
||||||
|
<option value="">Select a user</option>
|
||||||
|
@foreach($users as $user)
|
||||||
|
<option value="{{ $user->id }}" {{ old('user_id') == $user->id ? 'selected' : '' }}>
|
||||||
|
{{ $user->name }} ({{ $user->email }})
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('user_id')
|
||||||
|
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Provider Selection -->
|
||||||
|
<div>
|
||||||
|
<label for="provider" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
AI Provider <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="provider"
|
||||||
|
id="provider"
|
||||||
|
required
|
||||||
|
class="w-full rounded-md border-gray-300 @error('provider') border-red-500 @enderror"
|
||||||
|
onchange="updateProviderHelp()"
|
||||||
|
>
|
||||||
|
<option value="">Select a provider</option>
|
||||||
|
@foreach($providers as $key => $label)
|
||||||
|
<option value="{{ $key }}" {{ old('provider') == $key ? 'selected' : '' }}>
|
||||||
|
{{ $label }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('provider')
|
||||||
|
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
|
||||||
|
<!-- Provider-specific help text -->
|
||||||
|
<div id="provider-help" class="mt-2 text-sm text-gray-600">
|
||||||
|
<p class="hidden" data-provider="openai">
|
||||||
|
📝 Get your API key from: <a href="https://platform.openai.com/api-keys" target="_blank" class="text-blue-600 hover:underline">OpenAI Dashboard</a>
|
||||||
|
</p>
|
||||||
|
<p class="hidden" data-provider="anthropic">
|
||||||
|
📝 Get your API key from: <a href="https://console.anthropic.com/settings/keys" target="_blank" class="text-blue-600 hover:underline">Anthropic Console</a>
|
||||||
|
</p>
|
||||||
|
<p class="hidden" data-provider="mistral">
|
||||||
|
📝 Get your API key from: <a href="https://console.mistral.ai/api-keys" target="_blank" class="text-blue-600 hover:underline">Mistral Console</a>
|
||||||
|
</p>
|
||||||
|
<p class="hidden" data-provider="gemini">
|
||||||
|
📝 Get your API key from: <a href="https://makersuite.google.com/app/apikey" target="_blank" class="text-blue-600 hover:underline">Google AI Studio</a>
|
||||||
|
</p>
|
||||||
|
<p class="hidden" data-provider="deepseek">
|
||||||
|
📝 Get your API key from: <a href="https://platform.deepseek.com/api_keys" target="_blank" class="text-blue-600 hover:underline">DeepSeek Platform</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key -->
|
||||||
|
<div>
|
||||||
|
<label for="api_key" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
API Key <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="api_key"
|
||||||
|
id="api_key"
|
||||||
|
required
|
||||||
|
placeholder="sk-..."
|
||||||
|
class="w-full rounded-md border-gray-300 @error('api_key') border-red-500 @enderror"
|
||||||
|
value="{{ old('api_key') }}"
|
||||||
|
>
|
||||||
|
@error('api_key')
|
||||||
|
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
🔒 The API key will be encrypted before storage
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Organization ID (Optional) -->
|
||||||
|
<div>
|
||||||
|
<label for="organization_id" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Organization ID <span class="text-gray-400">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="organization_id"
|
||||||
|
id="organization_id"
|
||||||
|
placeholder="org-..."
|
||||||
|
class="w-full rounded-md border-gray-300 @error('organization_id') border-red-500 @enderror"
|
||||||
|
value="{{ old('organization_id') }}"
|
||||||
|
>
|
||||||
|
@error('organization_id')
|
||||||
|
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
Required for some OpenAI enterprise accounts
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Status -->
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="is_active"
|
||||||
|
id="is_active"
|
||||||
|
value="1"
|
||||||
|
checked
|
||||||
|
class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
|
||||||
|
>
|
||||||
|
<label for="is_active" class="ml-2 block text-sm text-gray-900">
|
||||||
|
Active (enable for immediate use)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div class="flex items-center justify-end space-x-3">
|
||||||
|
<a href="{{ route('admin.credentials.index') }}" class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Add Credentials
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Box -->
|
||||||
|
<div class="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<h3 class="font-semibold text-blue-900 mb-2">ℹ️ Important Information</h3>
|
||||||
|
<ul class="text-sm text-blue-800 space-y-1 list-disc list-inside">
|
||||||
|
<li>Each user can only have one set of credentials per provider</li>
|
||||||
|
<li>API keys are encrypted using Laravel's encryption (AES-256-CBC)</li>
|
||||||
|
<li>You can test credentials after creation to verify they work</li>
|
||||||
|
<li>Usage and costs will be tracked per user and provider</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
function updateProviderHelp() {
|
||||||
|
const provider = document.getElementById('provider').value;
|
||||||
|
const helpTexts = document.querySelectorAll('#provider-help p');
|
||||||
|
|
||||||
|
helpTexts.forEach(text => text.classList.add('hidden'));
|
||||||
|
|
||||||
|
if (provider) {
|
||||||
|
const selectedHelp = document.querySelector(`#provider-help p[data-provider="${provider}"]`);
|
||||||
|
if (selectedHelp) {
|
||||||
|
selectedHelp.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load if provider is already selected
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateProviderHelp();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
</x-app-layout>
|
||||||
208
laravel-app/resources/views/admin/credentials/edit.blade.php
Normal file
208
laravel-app/resources/views/admin/credentials/edit.blade.php
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||||
|
{{ __('Edit Provider Credentials') }}
|
||||||
|
</h2>
|
||||||
|
<div class="space-x-2">
|
||||||
|
<a href="{{ route('admin.credentials.show', $credential) }}" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
View Details
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('admin.credentials.index') }}" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Back to List
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="py-12">
|
||||||
|
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
|
||||||
|
@if(session('error'))
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||||
|
{{ session('error') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<form method="POST" action="{{ route('admin.credentials.update', $credential) }}" class="space-y-6">
|
||||||
|
@csrf
|
||||||
|
@method('PUT')
|
||||||
|
|
||||||
|
<!-- User (Read-only) -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
User
|
||||||
|
</label>
|
||||||
|
<div class="w-full px-3 py-2 bg-gray-100 rounded-md border border-gray-300">
|
||||||
|
<div class="font-medium">{{ $credential->user->name }}</div>
|
||||||
|
<div class="text-sm text-gray-500">{{ $credential->user->email }}</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
User cannot be changed after creation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Provider (Read-only) -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
AI Provider
|
||||||
|
</label>
|
||||||
|
<div class="w-full px-3 py-2 bg-gray-100 rounded-md border border-gray-300">
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
||||||
|
@if($credential->provider == 'openai') bg-green-100 text-green-800
|
||||||
|
@elseif($credential->provider == 'anthropic') bg-purple-100 text-purple-800
|
||||||
|
@elseif($credential->provider == 'mistral') bg-blue-100 text-blue-800
|
||||||
|
@elseif($credential->provider == 'gemini') bg-yellow-100 text-yellow-800
|
||||||
|
@else bg-gray-100 text-gray-800
|
||||||
|
@endif">
|
||||||
|
{{ $providers[$credential->provider] ?? ucfirst($credential->provider) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
Provider cannot be changed after creation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key (Update) -->
|
||||||
|
<div>
|
||||||
|
<label for="api_key" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
API Key <span class="text-gray-400">(leave empty to keep current)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="api_key"
|
||||||
|
id="api_key"
|
||||||
|
placeholder="sk-... (enter new key to update)"
|
||||||
|
class="w-full rounded-md border-gray-300 @error('api_key') border-red-500 @enderror"
|
||||||
|
value="{{ old('api_key') }}"
|
||||||
|
>
|
||||||
|
@error('api_key')
|
||||||
|
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
🔒 Current API key is encrypted and hidden. Enter a new key only if you want to update it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Organization ID -->
|
||||||
|
<div>
|
||||||
|
<label for="organization_id" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Organization ID <span class="text-gray-400">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="organization_id"
|
||||||
|
id="organization_id"
|
||||||
|
placeholder="org-..."
|
||||||
|
class="w-full rounded-md border-gray-300 @error('organization_id') border-red-500 @enderror"
|
||||||
|
value="{{ old('organization_id', $credential->organization_id) }}"
|
||||||
|
>
|
||||||
|
@error('organization_id')
|
||||||
|
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
Required for some OpenAI enterprise accounts
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Status -->
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="is_active"
|
||||||
|
id="is_active"
|
||||||
|
value="1"
|
||||||
|
{{ old('is_active', $credential->is_active) ? 'checked' : '' }}
|
||||||
|
class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
|
||||||
|
>
|
||||||
|
<label for="is_active" class="ml-2 block text-sm text-gray-900">
|
||||||
|
Active (enable for use in requests)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata Info -->
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<h3 class="font-semibold text-gray-900 mb-2">Metadata</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-600">Created:</span>
|
||||||
|
<span class="font-medium">{{ $credential->created_at->format('M d, Y H:i') }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-600">Last Updated:</span>
|
||||||
|
<span class="font-medium">{{ $credential->updated_at->format('M d, Y H:i') }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-600">Last Used:</span>
|
||||||
|
<span class="font-medium">{{ $credential->last_used_at ? $credential->last_used_at->diffForHumans() : 'Never' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div class="flex items-center justify-end space-x-3">
|
||||||
|
<a href="{{ route('admin.credentials.index') }}" class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="testCredential({{ $credential->id }})"
|
||||||
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
🧪 Test Current Key
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Update Credentials
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Box -->
|
||||||
|
<div class="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
|
<h3 class="font-semibold text-yellow-900 mb-2">⚠️ Important Notes</h3>
|
||||||
|
<ul class="text-sm text-yellow-800 space-y-1 list-disc list-inside">
|
||||||
|
<li>User and Provider cannot be changed once created</li>
|
||||||
|
<li>Test the API key before saving to ensure it works</li>
|
||||||
|
<li>Old API key will be replaced if you enter a new one</li>
|
||||||
|
<li>Disabling credentials will prevent any API requests using this key</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
function testCredential(credentialId) {
|
||||||
|
const button = event.target;
|
||||||
|
const originalText = button.textContent;
|
||||||
|
button.textContent = '🔄 Testing...';
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
fetch(`/admin/credentials/${credentialId}/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
alert(`✅ Success!\n\n${data.message}\n${data.details || ''}`);
|
||||||
|
} else {
|
||||||
|
alert(`❌ Failed!\n\n${data.message}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert(`❌ Error!\n\n${error.message}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
button.textContent = originalText;
|
||||||
|
button.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
</x-app-layout>
|
||||||
230
laravel-app/resources/views/admin/credentials/index.blade.php
Normal file
230
laravel-app/resources/views/admin/credentials/index.blade.php
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||||
|
{{ __('Provider Credentials') }}
|
||||||
|
</h2>
|
||||||
|
<a href="{{ route('admin.credentials.create') }}" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Add New Credentials
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="py-12">
|
||||||
|
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||||
|
<!-- Success/Error Messages -->
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
|
||||||
|
{{ session('success') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(session('error'))
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||||
|
{{ session('error') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg mb-6">
|
||||||
|
<div class="p-6">
|
||||||
|
<form method="GET" action="{{ route('admin.credentials.index') }}" class="space-y-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<!-- Provider Filter -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Provider</label>
|
||||||
|
<select name="provider" class="w-full rounded-md border-gray-300">
|
||||||
|
<option value="">All Providers</option>
|
||||||
|
@foreach($providers as $provider)
|
||||||
|
<option value="{{ $provider }}" {{ request('provider') == $provider ? 'selected' : '' }}>
|
||||||
|
{{ ucfirst($provider) }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Filter -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">User</label>
|
||||||
|
<select name="user_id" class="w-full rounded-md border-gray-300">
|
||||||
|
<option value="">All Users</option>
|
||||||
|
@foreach($users as $user)
|
||||||
|
<option value="{{ $user->id }}" {{ request('user_id') == $user->id ? 'selected' : '' }}>
|
||||||
|
{{ $user->name }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Filter -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
|
||||||
|
<select name="status" class="w-full rounded-md border-gray-300">
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="active" {{ request('status') == 'active' ? 'selected' : '' }}>Active</option>
|
||||||
|
<option value="inactive" {{ request('status') == 'inactive' ? 'selected' : '' }}>Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Search User</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
value="{{ request('search') }}"
|
||||||
|
placeholder="Name or email"
|
||||||
|
class="w-full rounded-md border-gray-300"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Apply Filters
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('admin.credentials.index') }}" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Reset
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Credentials Table -->
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
User
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Provider
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Organization ID
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Last Used
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
@forelse($credentials as $credential)
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm font-medium text-gray-900">{{ $credential->user->name }}</div>
|
||||||
|
<div class="text-sm text-gray-500">{{ $credential->user->email }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
||||||
|
@if($credential->provider == 'openai') bg-green-100 text-green-800
|
||||||
|
@elseif($credential->provider == 'anthropic') bg-purple-100 text-purple-800
|
||||||
|
@elseif($credential->provider == 'mistral') bg-blue-100 text-blue-800
|
||||||
|
@elseif($credential->provider == 'gemini') bg-yellow-100 text-yellow-800
|
||||||
|
@else bg-gray-100 text-gray-800
|
||||||
|
@endif">
|
||||||
|
{{ ucfirst($credential->provider) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{{ $credential->organization_id ?? '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
@if($credential->is_active)
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{{ $credential->last_used_at ? $credential->last_used_at->diffForHumans() : 'Never' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||||
|
<button
|
||||||
|
onclick="testCredential({{ $credential->id }})"
|
||||||
|
class="text-blue-600 hover:text-blue-900"
|
||||||
|
title="Test API Key">
|
||||||
|
🧪 Test
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('admin.credentials.show', $credential) }}" class="text-indigo-600 hover:text-indigo-900">
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('admin.credentials.edit', $credential) }}" class="text-yellow-600 hover:text-yellow-900">
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<form action="{{ route('admin.credentials.destroy', $credential) }}" method="POST" class="inline" onsubmit="return confirm('Are you sure you want to delete these credentials?');">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="text-red-600 hover:text-red-900">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-6 py-4 text-center text-gray-500">
|
||||||
|
No credentials found. <a href="{{ route('admin.credentials.create') }}" class="text-blue-600 hover:text-blue-900">Add your first credentials</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="mt-4">
|
||||||
|
{{ $credentials->links() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
function testCredential(credentialId) {
|
||||||
|
const button = event.target;
|
||||||
|
button.textContent = '🔄 Testing...';
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
fetch(`/admin/credentials/${credentialId}/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
alert(`✅ Success!\n\n${data.message}\n${data.details || ''}`);
|
||||||
|
} else {
|
||||||
|
alert(`❌ Failed!\n\n${data.message}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert(`❌ Error!\n\n${error.message}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
button.textContent = '🧪 Test';
|
||||||
|
button.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
</x-app-layout>
|
||||||
232
laravel-app/resources/views/admin/credentials/show.blade.php
Normal file
232
laravel-app/resources/views/admin/credentials/show.blade.php
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||||
|
{{ __('Provider Credentials Details') }}
|
||||||
|
</h2>
|
||||||
|
<div class="space-x-2">
|
||||||
|
<button
|
||||||
|
onclick="testCredential({{ $credential->id }})"
|
||||||
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
🧪 Test API Key
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('admin.credentials.edit', $credential) }}" class="bg-yellow-500 hover:bg-yellow-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('admin.credentials.index') }}" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Back to List
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="py-12">
|
||||||
|
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||||
|
|
||||||
|
<!-- Basic Information -->
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Basic Information</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- User -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">User</label>
|
||||||
|
<div class="text-lg font-semibold">{{ $credential->user->name }}</div>
|
||||||
|
<div class="text-sm text-gray-500">{{ $credential->user->email }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Provider -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Provider</label>
|
||||||
|
<span class="inline-flex items-center px-4 py-2 rounded-full text-base font-medium
|
||||||
|
@if($credential->provider == 'openai') bg-green-100 text-green-800
|
||||||
|
@elseif($credential->provider == 'anthropic') bg-purple-100 text-purple-800
|
||||||
|
@elseif($credential->provider == 'mistral') bg-blue-100 text-blue-800
|
||||||
|
@elseif($credential->provider == 'gemini') bg-yellow-100 text-yellow-800
|
||||||
|
@else bg-gray-100 text-gray-800
|
||||||
|
@endif">
|
||||||
|
{{ ucfirst($credential->provider) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Organization ID -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Organization ID</label>
|
||||||
|
<div class="text-base">{{ $credential->organization_id ?? 'Not set' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||||
|
@if($credential->is_active)
|
||||||
|
<span class="px-4 inline-flex text-base leading-7 font-semibold rounded-full bg-green-100 text-green-800">
|
||||||
|
✓ Active
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="px-4 inline-flex text-base leading-7 font-semibold rounded-full bg-red-100 text-red-800">
|
||||||
|
✗ Inactive
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Created At -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Created</label>
|
||||||
|
<div class="text-base">{{ $credential->created_at->format('M d, Y H:i') }}</div>
|
||||||
|
<div class="text-sm text-gray-500">{{ $credential->created_at->diffForHumans() }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Used -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Last Used</label>
|
||||||
|
<div class="text-base">
|
||||||
|
@if($credential->last_used_at)
|
||||||
|
{{ $credential->last_used_at->format('M d, Y H:i') }}
|
||||||
|
<div class="text-sm text-gray-500">{{ $credential->last_used_at->diffForHumans() }}</div>
|
||||||
|
@else
|
||||||
|
<span class="text-gray-500">Never used</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Usage Statistics -->
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Usage Statistics</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<!-- Total Requests -->
|
||||||
|
<div class="bg-blue-50 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-blue-600 font-medium">Total Requests</div>
|
||||||
|
<div class="text-2xl font-bold text-blue-900">{{ number_format($stats['total_requests']) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Total Cost -->
|
||||||
|
<div class="bg-green-50 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-green-600 font-medium">Total Cost</div>
|
||||||
|
<div class="text-2xl font-bold text-green-900">${{ number_format($stats['total_cost'], 2) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Total Tokens -->
|
||||||
|
<div class="bg-purple-50 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-purple-600 font-medium">Total Tokens</div>
|
||||||
|
<div class="text-2xl font-bold text-purple-900">{{ number_format($stats['total_tokens']) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last 30 Days -->
|
||||||
|
<div class="bg-yellow-50 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-yellow-600 font-medium">Last 30 Days</div>
|
||||||
|
<div class="text-2xl font-bold text-yellow-900">{{ number_format($stats['last_30_days_requests']) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Security Information -->
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Security Information</h3>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<span class="text-green-500 text-xl mr-3">🔒</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">Encryption Status</div>
|
||||||
|
<div class="text-sm text-gray-600">API key is encrypted using AES-256-CBC encryption</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start">
|
||||||
|
<span class="text-blue-500 text-xl mr-3">🔑</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">API Key Format</div>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
<code class="bg-gray-100 px-2 py-1 rounded">{{ $credential->provider }}-*********************</code>
|
||||||
|
(hidden for security)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start">
|
||||||
|
<span class="text-purple-500 text-xl mr-3">📊</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">Usage Tracking</div>
|
||||||
|
<div class="text-sm text-gray-600">All requests using this credential are logged and tracked</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Actions</h3>
|
||||||
|
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<button
|
||||||
|
onclick="testCredential({{ $credential->id }})"
|
||||||
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
🧪 Test API Key
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="{{ route('admin.credentials.edit', $credential) }}" class="bg-yellow-500 hover:bg-yellow-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
✏️ Edit Credentials
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form
|
||||||
|
action="{{ route('admin.credentials.destroy', $credential) }}"
|
||||||
|
method="POST"
|
||||||
|
onsubmit="return confirm('Are you sure you want to delete these credentials? This action cannot be undone.');"
|
||||||
|
class="inline">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
🗑️ Delete Credentials
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
function testCredential(credentialId) {
|
||||||
|
const button = event.target;
|
||||||
|
const originalText = button.textContent;
|
||||||
|
button.textContent = '🔄 Testing...';
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
fetch(`/admin/credentials/${credentialId}/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
alert(`✅ Success!\n\n${data.message}\n${data.details || ''}`);
|
||||||
|
} else {
|
||||||
|
alert(`❌ Failed!\n\n${data.message}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert(`❌ Error!\n\n${error.message}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
button.textContent = originalText;
|
||||||
|
button.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
</x-app-layout>
|
||||||
254
laravel-app/resources/views/admin/user-budget/show.blade.php
Normal file
254
laravel-app/resources/views/admin/user-budget/show.blade.php
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||||
|
Budget & Rate Limits - {{ $user->name }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="py-12">
|
||||||
|
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||||
|
|
||||||
|
<!-- Success Messages -->
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">
|
||||||
|
{{ session('success') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- User Info -->
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">User Information</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Name</label>
|
||||||
|
<div class="text-lg">{{ $user->name }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Email</label>
|
||||||
|
<div class="text-lg">{{ $user->email }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Member Since</label>
|
||||||
|
<div class="text-lg">{{ $user->created_at->format('M d, Y') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Budget Status -->
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-semibold">Budget Status</h3>
|
||||||
|
<form action="{{ route('admin.users.budget.reset', $user) }}" method="POST"
|
||||||
|
onsubmit="return confirm('Are you sure you want to reset this user\'s budget?');">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="bg-yellow-500 hover:bg-yellow-700 text-white font-bold py-2 px-4 rounded text-sm">
|
||||||
|
Reset Budget
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Budget Overview -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="bg-blue-50 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-blue-600 font-medium">Monthly Limit</div>
|
||||||
|
<div class="text-2xl font-bold text-blue-900">${{ number_format($budgetStatus['monthly_limit'], 2) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-50 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-green-600 font-medium">Daily Limit</div>
|
||||||
|
<div class="text-2xl font-bold text-green-900">${{ number_format($budgetStatus['daily_limit'], 2) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-orange-50 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-orange-600 font-medium">Month Spending</div>
|
||||||
|
<div class="text-2xl font-bold text-orange-900">${{ number_format($budgetStatus['current_month_spending'], 2) }}</div>
|
||||||
|
<div class="text-xs text-orange-700">{{ round($budgetStatus['monthly_usage_percentage'], 1) }}% used</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-purple-50 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-purple-600 font-medium">Today Spending</div>
|
||||||
|
<div class="text-2xl font-bold text-purple-900">${{ number_format($budgetStatus['current_day_spending'], 2) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Bars -->
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between mb-1">
|
||||||
|
<span class="text-sm font-medium">Monthly Budget Usage</span>
|
||||||
|
<span class="text-sm font-medium">{{ round($budgetStatus['monthly_usage_percentage'], 1) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2.5">
|
||||||
|
<div class="h-2.5 rounded-full {{ $budgetStatus['is_exceeded'] ? 'bg-red-600' : ($budgetStatus['monthly_usage_percentage'] >= 80 ? 'bg-yellow-500' : 'bg-blue-600') }}"
|
||||||
|
style="width: {{ min(100, $budgetStatus['monthly_usage_percentage']) }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Budget Form -->
|
||||||
|
<form action="{{ route('admin.users.budget.update', $user) }}" method="POST" class="space-y-4">
|
||||||
|
@csrf
|
||||||
|
@method('PUT')
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="monthly_limit" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Monthly Limit ($)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
name="monthly_limit"
|
||||||
|
id="monthly_limit"
|
||||||
|
value="{{ $budgetStatus['monthly_limit'] }}"
|
||||||
|
class="w-full rounded-md border-gray-300"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="daily_limit" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Daily Limit ($)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
name="daily_limit"
|
||||||
|
id="daily_limit"
|
||||||
|
value="{{ $budgetStatus['daily_limit'] }}"
|
||||||
|
class="w-full rounded-md border-gray-300"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="alert_threshold_percentage" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Alert Threshold (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="alert_threshold_percentage"
|
||||||
|
id="alert_threshold_percentage"
|
||||||
|
value="80"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
class="w-full rounded-md border-gray-300"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Update Budget Limits
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rate Limit Status -->
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-semibold">Rate Limit Status</h3>
|
||||||
|
<form action="{{ route('admin.users.rate-limit.reset', $user) }}" method="POST"
|
||||||
|
onsubmit="return confirm('Are you sure you want to reset this user\'s rate limits?');">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="bg-yellow-500 hover:bg-yellow-700 text-white font-bold py-2 px-4 rounded text-sm">
|
||||||
|
Reset Rate Limits
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rate Limit Overview -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
<div class="bg-blue-50 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-blue-600 font-medium">Per Minute</div>
|
||||||
|
<div class="text-2xl font-bold text-blue-900">
|
||||||
|
{{ $rateLimitStatus['current_minute_count'] }} / {{ $rateLimitStatus['requests_per_minute'] }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-blue-700">{{ $rateLimitStatus['minute_remaining'] }} remaining</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-50 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-green-600 font-medium">Per Hour</div>
|
||||||
|
<div class="text-2xl font-bold text-green-900">
|
||||||
|
{{ $rateLimitStatus['current_hour_count'] }} / {{ $rateLimitStatus['requests_per_hour'] }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-green-700">{{ $rateLimitStatus['hour_remaining'] }} remaining</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-purple-50 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-purple-600 font-medium">Per Day</div>
|
||||||
|
<div class="text-2xl font-bold text-purple-900">
|
||||||
|
{{ $rateLimitStatus['current_day_count'] }} / {{ $rateLimitStatus['requests_per_day'] }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-purple-700">{{ $rateLimitStatus['day_remaining'] }} remaining</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($rateLimitStatus['is_rate_limited'])
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||||
|
⚠️ User is currently rate limited until {{ $rateLimitStatus['rate_limit_expires_at']->format('H:i:s') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Edit Rate Limit Form -->
|
||||||
|
<form action="{{ route('admin.users.rate-limit.update', $user) }}" method="POST" class="space-y-4">
|
||||||
|
@csrf
|
||||||
|
@method('PUT')
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="requests_per_minute" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Requests Per Minute
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="requests_per_minute"
|
||||||
|
id="requests_per_minute"
|
||||||
|
value="{{ $rateLimitStatus['requests_per_minute'] }}"
|
||||||
|
min="0"
|
||||||
|
class="w-full rounded-md border-gray-300"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="requests_per_hour" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Requests Per Hour
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="requests_per_hour"
|
||||||
|
id="requests_per_hour"
|
||||||
|
value="{{ $rateLimitStatus['requests_per_hour'] }}"
|
||||||
|
min="0"
|
||||||
|
class="w-full rounded-md border-gray-300"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="requests_per_day" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Requests Per Day
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="requests_per_day"
|
||||||
|
id="requests_per_day"
|
||||||
|
value="{{ $rateLimitStatus['requests_per_day'] }}"
|
||||||
|
min="0"
|
||||||
|
class="w-full rounded-md border-gray-300"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Update Rate Limits
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
129
laravel-app/resources/views/admin/users/index.blade.php
Normal file
129
laravel-app/resources/views/admin/users/index.blade.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header">
|
||||||
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||||
|
{{ __('User Management') }}
|
||||||
|
</h2>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="py-12">
|
||||||
|
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||||
|
|
||||||
|
<!-- Search Filter -->
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg mb-6">
|
||||||
|
<div class="p-6">
|
||||||
|
<form method="GET" action="{{ route('admin.users.index') }}" class="space-y-4">
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
value="{{ request('search') }}"
|
||||||
|
placeholder="Search by name or email"
|
||||||
|
class="w-full rounded-md border-gray-300"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('admin.users.index') }}" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Reset
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Table -->
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
User
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Total Requests
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Monthly Budget
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Budget Status
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
@forelse($users as $user)
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm font-medium text-gray-900">{{ $user->name }}</div>
|
||||||
|
<div class="text-sm text-gray-500">{{ $user->email }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{{ number_format($user->llm_requests_count) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
@if($user->budget)
|
||||||
|
${{ number_format($user->budget->monthly_limit, 2) }}
|
||||||
|
@else
|
||||||
|
<span class="text-gray-400">Not set</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
@if($user->budget)
|
||||||
|
@php
|
||||||
|
$percentage = $user->budget->monthly_limit > 0
|
||||||
|
? ($user->budget->current_month_spending / $user->budget->monthly_limit) * 100
|
||||||
|
: 0;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if($user->budget->is_budget_exceeded)
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||||
|
Exceeded
|
||||||
|
</span>
|
||||||
|
@elseif($percentage >= 80)
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||||
|
{{ round($percentage) }}%
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||||
|
{{ round($percentage) }}%
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||||
|
No budget
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<a href="{{ route('admin.users.budget.show', $user) }}" class="text-indigo-600 hover:text-indigo-900">
|
||||||
|
Manage Budget
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-6 py-4 text-center text-gray-500">
|
||||||
|
No users found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="mt-4">
|
||||||
|
{{ $users->links() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -183,10 +183,10 @@
|
|||||||
{{ $key->created_at->format('Y-m-d H:i') }}
|
{{ $key->created_at->format('Y-m-d H:i') }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<a href="{{ route('api-keys.show', $key->id) }}"
|
<a href="{{ route('api-keys.show', $key->token) }}"
|
||||||
class="text-blue-600 hover:text-blue-900 mr-3">View</a>
|
class="text-blue-600 hover:text-blue-900 mr-3">View</a>
|
||||||
@if($key->is_active && !$key->is_expired)
|
@if($key->is_active && !$key->is_expired)
|
||||||
<form action="{{ route('api-keys.revoke', $key->id) }}"
|
<form action="{{ route('api-keys.revoke', $key->token) }}"
|
||||||
method="POST"
|
method="POST"
|
||||||
class="inline"
|
class="inline"
|
||||||
onsubmit="return confirm('Are you sure you want to revoke this API key? This action cannot be undone.');">
|
onsubmit="return confirm('Are you sure you want to revoke this API key? This action cannot be undone.');">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<x-app-layout>
|
<x-app-layout>
|
||||||
<x-slot name="header">
|
<x-slot name="header">
|
||||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||||
{{ __('Dashboard') }} - Any-LLM Gateway
|
{{ __('Dashboard') }} - LLM Gateway
|
||||||
</h2>
|
</h2>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
@@ -11,16 +11,16 @@
|
|||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<!-- Total Users -->
|
<!-- Total Users -->
|
||||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Total Users</p>
|
<p class="text-sm text-gray-600">Total Users</p>
|
||||||
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
<p class="text-3xl font-bold text-gray-900">
|
||||||
{{ number_format($stats['total_users']) }}
|
{{ number_format($stats['total_users']) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
{{ $stats['active_users'] }} active, {{ $stats['blocked_users'] }} blocked
|
{{ $stats['active_credentials'] }} active credentials
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-blue-500">
|
<div class="text-blue-500">
|
||||||
@@ -33,15 +33,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Requests Today -->
|
<!-- Requests Today -->
|
||||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Requests Today</p>
|
<p class="text-sm text-gray-600">Requests Today</p>
|
||||||
<p class="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
<p class="text-3xl font-bold text-blue-600">
|
||||||
{{ number_format($stats['total_requests_today']) }}
|
{{ number_format($stats['total_requests_today']) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
{{ number_format($stats['total_requests_month']) }} this month
|
{{ number_format($stats['total_requests_month']) }} this month
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,15 +55,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Spend Today -->
|
<!-- Spend Today -->
|
||||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Spend Today</p>
|
<p class="text-sm text-gray-600">Spend Today</p>
|
||||||
<p class="text-3xl font-bold text-green-600 dark:text-green-400">
|
<p class="text-3xl font-bold text-green-600">
|
||||||
${{ number_format($stats['total_spend_today'], 2) }}
|
${{ number_format($stats['total_spend_today'], 2) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
${{ number_format($stats['total_spend_month'], 2) }} this month
|
${{ number_format($stats['total_spend_month'], 2) }} this month
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,16 +77,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tokens Today -->
|
<!-- Tokens Today -->
|
||||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Tokens Today</p>
|
<p class="text-sm text-gray-600">Tokens Today</p>
|
||||||
<p class="text-3xl font-bold text-purple-600 dark:text-purple-400">
|
<p class="text-3xl font-bold text-purple-600">
|
||||||
{{ number_format($stats['total_tokens_today']) }}
|
{{ number_format($stats['total_tokens_today']) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
Prompt + Completion
|
Avg: ${{ number_format($stats['avg_cost_per_request'], 4) }}/req
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-purple-500">
|
<div class="text-purple-500">
|
||||||
@@ -100,9 +100,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Usage Trend Chart -->
|
<!-- Usage Trend Chart -->
|
||||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
||||||
Usage Trend (Last 30 Days)
|
Usage Trend (Last 30 Days)
|
||||||
</h3>
|
</h3>
|
||||||
<canvas id="usageChart" height="80"></canvas>
|
<canvas id="usageChart" height="80"></canvas>
|
||||||
@@ -112,9 +112,9 @@
|
|||||||
<!-- Provider Stats & Top Users -->
|
<!-- Provider Stats & Top Users -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<!-- Provider Breakdown -->
|
<!-- Provider Breakdown -->
|
||||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
||||||
Usage by Provider
|
Usage by Provider
|
||||||
</h3>
|
</h3>
|
||||||
<canvas id="providerChart" height="250"></canvas>
|
<canvas id="providerChart" height="250"></canvas>
|
||||||
@@ -122,30 +122,33 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Top Users -->
|
<!-- Top Users -->
|
||||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
||||||
Top Users by Spend
|
Top Users by Spend
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@forelse($topUsers as $user)
|
@forelse($topUsers as $user)
|
||||||
<div class="flex items-center justify-between border-b border-gray-200 dark:border-gray-700 pb-3">
|
<div class="flex items-center justify-between border-b border-gray-200 pb-3">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="font-medium text-gray-900 dark:text-gray-100">
|
<p class="font-medium text-gray-900">
|
||||||
{{ $user->alias ?? $user->user_id }}
|
{{ $user->name }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
<p class="text-sm text-gray-500">
|
||||||
{{ number_format($user->usage_logs_count ?? 0) }} requests
|
{{ number_format($user->llm_requests_count ?? 0) }} requests
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="font-semibold text-green-600 dark:text-green-400">
|
<p class="font-semibold text-green-600">
|
||||||
${{ number_format($user->usage_logs_sum_cost ?? 0, 2) }}
|
${{ number_format($user->total_cost ?? 0, 2) }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
{{ number_format($user->total_tokens ?? 0) }} tokens
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@empty
|
@empty
|
||||||
<p class="text-gray-500 dark:text-gray-400 text-center py-4">
|
<p class="text-gray-500 text-center py-4">
|
||||||
No usage data yet
|
No usage data yet
|
||||||
</p>
|
</p>
|
||||||
@endforelse
|
@endforelse
|
||||||
@@ -155,40 +158,52 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Model Stats -->
|
<!-- Model Stats -->
|
||||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
||||||
Most Used Models
|
Most Used Models
|
||||||
</h3>
|
</h3>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50 dark:bg-gray-900">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Model</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Model</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Requests</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Provider</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tokens</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Requests</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Cost</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tokens</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Cost</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
@forelse($modelStats as $model)
|
@forelse($modelStats as $model)
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
{{ $model->model }}
|
{{ $model->model }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium
|
||||||
|
@if($model->provider == 'openai') bg-green-100 text-green-800
|
||||||
|
@elseif($model->provider == 'anthropic') bg-purple-100 text-purple-800
|
||||||
|
@elseif($model->provider == 'mistral') bg-blue-100 text-blue-800
|
||||||
|
@elseif($model->provider == 'gemini') bg-yellow-100 text-yellow-800
|
||||||
|
@else bg-gray-100 text-gray-800
|
||||||
|
@endif">
|
||||||
|
{{ ucfirst($model->provider) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
{{ number_format($model->count) }}
|
{{ number_format($model->count) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
{{ number_format($model->tokens ?? 0) }}
|
{{ number_format($model->tokens ?? 0) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
${{ number_format($model->total_cost ?? 0, 4) }}
|
${{ number_format($model->total_cost ?? 0, 4) }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">
|
<td colspan="5" class="px-6 py-4 text-center text-gray-500">
|
||||||
No usage data yet
|
No usage data yet
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -245,12 +260,6 @@
|
|||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: 'Requests'
|
text: 'Requests'
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
color: getComputedStyle(document.documentElement).getPropertyValue('--tw-text-opacity') ? '#9CA3AF' : '#6B7280'
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
color: 'rgba(156, 163, 175, 0.1)'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
y1: {
|
y1: {
|
||||||
@@ -261,27 +270,9 @@
|
|||||||
display: true,
|
display: true,
|
||||||
text: 'Cost ($)'
|
text: 'Cost ($)'
|
||||||
},
|
},
|
||||||
ticks: {
|
|
||||||
color: getComputedStyle(document.documentElement).getPropertyValue('--tw-text-opacity') ? '#9CA3AF' : '#6B7280'
|
|
||||||
},
|
|
||||||
grid: {
|
grid: {
|
||||||
drawOnChartArea: false,
|
drawOnChartArea: false,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
x: {
|
|
||||||
ticks: {
|
|
||||||
color: getComputedStyle(document.documentElement).getPropertyValue('--tw-text-opacity') ? '#9CA3AF' : '#6B7280'
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
color: 'rgba(156, 163, 175, 0.1)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
labels: {
|
|
||||||
color: getComputedStyle(document.documentElement).getPropertyValue('--tw-text-opacity') ? '#9CA3AF' : '#6B7280'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,19 +283,19 @@
|
|||||||
new Chart(providerCtx, {
|
new Chart(providerCtx, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
data: {
|
data: {
|
||||||
labels: @json($providerStats->pluck('provider')),
|
labels: @json($providerStats->pluck('provider')->map(fn($p) => ucfirst($p))),
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: @json($providerStats->pluck('count')),
|
data: @json($providerStats->pluck('count')),
|
||||||
backgroundColor: [
|
backgroundColor: [
|
||||||
'rgba(59, 130, 246, 0.8)',
|
'rgba(34, 197, 94, 0.8)', // Green - OpenAI
|
||||||
'rgba(16, 185, 129, 0.8)',
|
'rgba(168, 85, 247, 0.8)', // Purple - Anthropic
|
||||||
'rgba(249, 115, 22, 0.8)',
|
'rgba(59, 130, 246, 0.8)', // Blue - Mistral
|
||||||
'rgba(168, 85, 247, 0.8)',
|
'rgba(251, 191, 36, 0.8)', // Yellow - Gemini
|
||||||
'rgba(236, 72, 153, 0.8)',
|
'rgba(236, 72, 153, 0.8)', // Pink - DeepSeek
|
||||||
'rgba(245, 158, 11, 0.8)',
|
'rgba(249, 115, 22, 0.8)', // Orange
|
||||||
],
|
],
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: '#1f2937'
|
borderColor: '#fff'
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
@@ -314,7 +305,6 @@
|
|||||||
legend: {
|
legend: {
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
labels: {
|
labels: {
|
||||||
color: getComputedStyle(document.documentElement).getPropertyValue('--tw-text-opacity') ? '#9CA3AF' : '#6B7280',
|
|
||||||
padding: 15
|
padding: 15
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -322,11 +312,10 @@
|
|||||||
callbacks: {
|
callbacks: {
|
||||||
label: function(context) {
|
label: function(context) {
|
||||||
let label = context.label || '';
|
let label = context.label || '';
|
||||||
if (label) {
|
let value = context.parsed || 0;
|
||||||
label += ': ';
|
let total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||||
}
|
let percentage = ((value / total) * 100).toFixed(1);
|
||||||
label += context.parsed + ' requests';
|
return label + ': ' + value + ' requests (' + percentage + '%)';
|
||||||
return label;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ new class extends Component
|
|||||||
<x-nav-link :href="route('model-pricing.index')" :active="request()->routeIs('model-pricing.*')" wire:navigate>
|
<x-nav-link :href="route('model-pricing.index')" :active="request()->routeIs('model-pricing.*')" wire:navigate>
|
||||||
{{ __('Pricing') }}
|
{{ __('Pricing') }}
|
||||||
</x-nav-link>
|
</x-nav-link>
|
||||||
|
<x-nav-link :href="route('admin.credentials.index')" :active="request()->routeIs('admin.credentials.*')" wire:navigate>
|
||||||
|
{{ __('Credentials') }}
|
||||||
|
</x-nav-link>
|
||||||
|
<x-nav-link :href="route('admin.users.index')" :active="request()->routeIs('admin.users.*')" wire:navigate>
|
||||||
|
{{ __('User Budgets') }}
|
||||||
|
</x-nav-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -114,6 +120,12 @@ new class extends Component
|
|||||||
<x-responsive-nav-link :href="route('model-pricing.index')" :active="request()->routeIs('model-pricing.*')" wire:navigate>
|
<x-responsive-nav-link :href="route('model-pricing.index')" :active="request()->routeIs('model-pricing.*')" wire:navigate>
|
||||||
{{ __('Pricing') }}
|
{{ __('Pricing') }}
|
||||||
</x-responsive-nav-link>
|
</x-responsive-nav-link>
|
||||||
|
<x-responsive-nav-link :href="route('admin.credentials.index')" :active="request()->routeIs('admin.credentials.*')" wire:navigate>
|
||||||
|
{{ __('Credentials') }}
|
||||||
|
</x-responsive-nav-link>
|
||||||
|
<x-responsive-nav-link :href="route('admin.users.index')" :active="request()->routeIs('admin.users.*')" wire:navigate>
|
||||||
|
{{ __('User Budgets') }}
|
||||||
|
</x-responsive-nav-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Responsive Settings Options -->
|
<!-- Responsive Settings Options -->
|
||||||
|
|||||||
@@ -9,50 +9,129 @@
|
|||||||
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<form method="POST" action="{{ route('model-pricing.store') }}">
|
<form method="POST" action="{{ route('model-pricing.store') }}" id="pricingForm">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
|
<!-- Provider Selection -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="model_key" class="block text-sm font-medium text-gray-700 mb-1">
|
<label for="provider" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Model Key *
|
Provider *
|
||||||
</label>
|
</label>
|
||||||
<input type="text" name="model_key" id="model_key"
|
<select name="provider" id="provider" required
|
||||||
value="{{ old('model_key') }}" required
|
|
||||||
placeholder="e.g., gpt-4, claude-3-opus-20240229"
|
|
||||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
@error('model_key')
|
<option value="">Select a provider...</option>
|
||||||
|
<option value="openai" {{ old('provider') == 'openai' ? 'selected' : '' }}>OpenAI (models will be loaded from API)</option>
|
||||||
|
<option value="anthropic" {{ old('provider') == 'anthropic' ? 'selected' : '' }}>Anthropic (models will be loaded from API)</option>
|
||||||
|
<option value="deepseek" {{ old('provider') == 'deepseek' ? 'selected' : '' }}>DeepSeek (models will be loaded from API)</option>
|
||||||
|
<option value="google" {{ old('provider') == 'google' ? 'selected' : '' }}>Google Gemini (models will be loaded from API)</option>
|
||||||
|
<option value="mistral" {{ old('provider') == 'mistral' ? 'selected' : '' }}>Mistral AI (models will be loaded from API)</option>
|
||||||
|
<option value="cohere" {{ old('provider') == 'cohere' ? 'selected' : '' }}>Cohere (manual entry)</option>
|
||||||
|
<option value="other" {{ old('provider') == 'other' ? 'selected' : '' }}>Other</option>
|
||||||
|
</select>
|
||||||
|
@error('provider')
|
||||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Model Selection (Dropdown for API providers) -->
|
||||||
|
<div class="mb-4" id="modelSelectContainer">
|
||||||
|
<label for="modelSelect" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Model *
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<select name="model" id="modelSelect" required
|
||||||
|
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
<option value="">Select provider first...</option>
|
||||||
|
</select>
|
||||||
|
<div id="modelLoading" class="hidden absolute right-10 top-2">
|
||||||
|
<svg class="animate-spin h-5 w-5 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@error('model')
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
<p class="mt-1 text-xs text-gray-500" id="modelHint">Select a provider to load available models</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model Input (Text for manual entry) -->
|
||||||
|
<div class="mb-4 hidden" id="modelInputContainer">
|
||||||
|
<label for="modelInput" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Model Name *
|
||||||
|
</label>
|
||||||
|
<input type="text" name="model" id="modelInput"
|
||||||
|
value="{{ old('model') }}"
|
||||||
|
placeholder="e.g., claude-3-5-sonnet-20241022"
|
||||||
|
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
<p class="mt-1 text-xs text-gray-500" id="modelInputHint">Enter the exact model name</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input Price -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="input_price_per_million" class="block text-sm font-medium text-gray-700 mb-1">
|
<label for="input_price_per_million" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Input Price per Million Tokens *
|
Input Price per Million Tokens *
|
||||||
</label>
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<span class="absolute left-3 top-2 text-gray-500">$</span>
|
||||||
<input type="number" name="input_price_per_million" id="input_price_per_million"
|
<input type="number" name="input_price_per_million" id="input_price_per_million"
|
||||||
value="{{ old('input_price_per_million') }}" step="0.01" min="0" required
|
value="{{ old('input_price_per_million') }}" step="0.01" min="0" required
|
||||||
placeholder="e.g., 3.00"
|
placeholder="3.00"
|
||||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
class="w-full pl-7 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
@error('input_price_per_million')
|
@error('input_price_per_million')
|
||||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
@enderror
|
@enderror
|
||||||
<p class="mt-1 text-xs text-gray-500">Price in USD per 1 million input tokens</p>
|
<p class="mt-1 text-xs text-gray-500">Price in USD per 1 million input tokens</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-6">
|
<!-- Output Price -->
|
||||||
|
<div class="mb-4">
|
||||||
<label for="output_price_per_million" class="block text-sm font-medium text-gray-700 mb-1">
|
<label for="output_price_per_million" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Output Price per Million Tokens *
|
Output Price per Million Tokens *
|
||||||
</label>
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<span class="absolute left-3 top-2 text-gray-500">$</span>
|
||||||
<input type="number" name="output_price_per_million" id="output_price_per_million"
|
<input type="number" name="output_price_per_million" id="output_price_per_million"
|
||||||
value="{{ old('output_price_per_million') }}" step="0.01" min="0" required
|
value="{{ old('output_price_per_million') }}" step="0.01" min="0" required
|
||||||
placeholder="e.g., 15.00"
|
placeholder="15.00"
|
||||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
class="w-full pl-7 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
@error('output_price_per_million')
|
@error('output_price_per_million')
|
||||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
@enderror
|
@enderror
|
||||||
<p class="mt-1 text-xs text-gray-500">Price in USD per 1 million output tokens</p>
|
<p class="mt-1 text-xs text-gray-500">Price in USD per 1 million output tokens</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Context Window (Optional) -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="context_window" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Context Window (tokens)
|
||||||
|
</label>
|
||||||
|
<input type="number" name="context_window" id="context_window"
|
||||||
|
value="{{ old('context_window') }}" min="0"
|
||||||
|
placeholder="e.g., 128000"
|
||||||
|
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
@error('context_window')
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Max Output Tokens (Optional) -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="max_output_tokens" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Max Output Tokens
|
||||||
|
</label>
|
||||||
|
<input type="number" name="max_output_tokens" id="max_output_tokens"
|
||||||
|
value="{{ old('max_output_tokens') }}" min="0"
|
||||||
|
placeholder="e.g., 4096"
|
||||||
|
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
@error('max_output_tokens')
|
||||||
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<a href="{{ route('model-pricing.index') }}"
|
<a href="{{ route('model-pricing.index') }}"
|
||||||
class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded">
|
class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded">
|
||||||
@@ -68,4 +147,94 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
const providerSelect = document.getElementById('provider');
|
||||||
|
const modelSelectContainer = document.getElementById('modelSelectContainer');
|
||||||
|
const modelInputContainer = document.getElementById('modelInputContainer');
|
||||||
|
const modelSelect = document.getElementById('modelSelect');
|
||||||
|
const modelInput = document.getElementById('modelInput');
|
||||||
|
const modelHint = document.getElementById('modelHint');
|
||||||
|
const modelInputHint = document.getElementById('modelInputHint');
|
||||||
|
const modelLoading = document.getElementById('modelLoading');
|
||||||
|
|
||||||
|
// Fetch models when provider changes
|
||||||
|
providerSelect.addEventListener('change', async function() {
|
||||||
|
const provider = this.value;
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
modelSelect.innerHTML = '<option value="">Select a model...</option>';
|
||||||
|
modelInput.value = '';
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
showModelSelect();
|
||||||
|
modelSelect.disabled = true;
|
||||||
|
modelHint.textContent = 'Select a provider to load available models';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'other') {
|
||||||
|
showModelInput();
|
||||||
|
modelInputHint.textContent = 'Enter custom model name';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading indicator
|
||||||
|
showModelSelect();
|
||||||
|
modelLoading.classList.remove('hidden');
|
||||||
|
modelSelect.disabled = true;
|
||||||
|
modelHint.textContent = 'Loading models from ' + provider + '...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/provider-models/${provider}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.models) {
|
||||||
|
// Provider has API - populate dropdown
|
||||||
|
data.models.forEach(model => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = model.id;
|
||||||
|
option.textContent = model.name;
|
||||||
|
modelSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelSelect.disabled = false;
|
||||||
|
modelHint.textContent = `✓ ${data.models.length} models loaded from ${provider} API`;
|
||||||
|
} else {
|
||||||
|
// Provider doesn't have API - switch to text input
|
||||||
|
showModelInput();
|
||||||
|
modelInputHint.innerHTML = `<span class="text-yellow-600">${data.message}</span>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showModelInput();
|
||||||
|
modelInputHint.innerHTML = `<span class="text-red-600">Error loading models: ${error.message}. Please enter manually.</span>`;
|
||||||
|
} finally {
|
||||||
|
modelLoading.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showModelSelect() {
|
||||||
|
modelSelectContainer.classList.remove('hidden');
|
||||||
|
modelInputContainer.classList.add('hidden');
|
||||||
|
modelSelect.required = true;
|
||||||
|
modelInput.required = false;
|
||||||
|
modelInput.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showModelInput() {
|
||||||
|
modelSelectContainer.classList.add('hidden');
|
||||||
|
modelInputContainer.classList.remove('hidden');
|
||||||
|
modelSelect.required = false;
|
||||||
|
modelSelect.disabled = true;
|
||||||
|
modelInput.required = true;
|
||||||
|
modelInput.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger change event if provider is pre-selected
|
||||||
|
if (providerSelect.value) {
|
||||||
|
providerSelect.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
</x-app-layout>
|
</x-app-layout>
|
||||||
|
|||||||
@@ -35,6 +35,9 @@
|
|||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Provider
|
||||||
|
</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Model
|
Model
|
||||||
</th>
|
</th>
|
||||||
@@ -44,6 +47,9 @@
|
|||||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Output Price
|
Output Price
|
||||||
</th>
|
</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
@@ -53,7 +59,10 @@
|
|||||||
@foreach($modelPricing as $model)
|
@foreach($modelPricing as $model)
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
{{ $model->model_key }}
|
{{ $model->provider }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ $model->model }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-right text-blue-600 font-semibold">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-right text-blue-600 font-semibold">
|
||||||
{{ $model->input_price_formatted }}
|
{{ $model->input_price_formatted }}
|
||||||
@@ -61,10 +70,21 @@
|
|||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-right text-green-600 font-semibold">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-right text-green-600 font-semibold">
|
||||||
{{ $model->output_price_formatted }}
|
{{ $model->output_price_formatted }}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-center">
|
||||||
|
@if($model->is_active)
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<a href="{{ route('model-pricing.edit', $model->model_key) }}"
|
<a href="{{ route('model-pricing.edit', $model->id) }}"
|
||||||
class="text-indigo-600 hover:text-indigo-900 mr-3">Edit</a>
|
class="text-indigo-600 hover:text-indigo-900 mr-3">Edit</a>
|
||||||
<form action="{{ route('model-pricing.destroy', $model->model_key) }}"
|
<form action="{{ route('model-pricing.destroy', $model->id) }}"
|
||||||
method="POST" class="inline"
|
method="POST" class="inline"
|
||||||
onsubmit="return confirm('Are you sure?');">
|
onsubmit="return confirm('Are you sure?');">
|
||||||
@csrf
|
@csrf
|
||||||
|
|||||||
27
laravel-app/routes/api.php
Normal file
27
laravel-app/routes/api.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use App\Http\Controllers\Api\ChatCompletionController;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| API Routes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here is where you can register API routes for your application. These
|
||||||
|
| routes are loaded by the RouteServiceProvider and all of them will
|
||||||
|
| be assigned to the "api" middleware group. Make something great!
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
Route::middleware('auth:sanctum')->group(function () {
|
||||||
|
// Chat Completion Endpoint
|
||||||
|
Route::post('/chat/completions', [ChatCompletionController::class, 'create'])
|
||||||
|
->middleware(['checkbudget', 'checkratelimit']);
|
||||||
|
|
||||||
|
// User info endpoint
|
||||||
|
Route::get('/user', function (Request $request) {
|
||||||
|
return $request->user();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,15 @@
|
|||||||
|
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Schedule;
|
||||||
|
use App\Jobs\ResetDailyBudgets;
|
||||||
|
use App\Jobs\ResetMonthlyBudgets;
|
||||||
|
|
||||||
Artisan::command('inspire', function () {
|
Artisan::command('inspire', function () {
|
||||||
$this->comment(Inspiring::quote());
|
$this->comment(Inspiring::quote());
|
||||||
})->purpose('Display an inspiring quote');
|
})->purpose('Display an inspiring quote');
|
||||||
|
|
||||||
|
// Schedule budget reset jobs
|
||||||
|
Schedule::job(new ResetDailyBudgets)->dailyAt('00:00')->name('reset-daily-budgets');
|
||||||
|
Schedule::job(new ResetMonthlyBudgets)->monthlyOn(1, '00:00')->name('reset-monthly-budgets');
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ use App\Http\Controllers\ApiKeyController;
|
|||||||
use App\Http\Controllers\BudgetController;
|
use App\Http\Controllers\BudgetController;
|
||||||
use App\Http\Controllers\UsageLogController;
|
use App\Http\Controllers\UsageLogController;
|
||||||
use App\Http\Controllers\ModelPricingController;
|
use App\Http\Controllers\ModelPricingController;
|
||||||
|
use App\Http\Controllers\Admin\CredentialController;
|
||||||
|
use App\Http\Controllers\Admin\UserBudgetController;
|
||||||
|
use App\Http\Controllers\Admin\UserManagementController;
|
||||||
|
|
||||||
Route::view('/', 'welcome');
|
Route::view('/', 'welcome');
|
||||||
|
|
||||||
Route::middleware(['auth', 'verified'])->group(function () {
|
Route::middleware(['auth', 'verified'])->group(function () {
|
||||||
// Dashboard
|
// Dashboard
|
||||||
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
||||||
|
Route::get('dashboard/realtime-stats', [DashboardController::class, 'realtimeStats'])->name('dashboard.realtime-stats');
|
||||||
|
|
||||||
// Gateway Users Management
|
// Gateway Users Management
|
||||||
Route::resource('gateway-users', GatewayUserController::class);
|
Route::resource('gateway-users', GatewayUserController::class);
|
||||||
@@ -41,6 +45,33 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::post('model-pricing-calculate', [ModelPricingController::class, 'calculate'])->name('model-pricing.calculate');
|
Route::post('model-pricing-calculate', [ModelPricingController::class, 'calculate'])->name('model-pricing.calculate');
|
||||||
Route::get('model-pricing-import', [ModelPricingController::class, 'importForm'])->name('model-pricing.import-form');
|
Route::get('model-pricing-import', [ModelPricingController::class, 'importForm'])->name('model-pricing.import-form');
|
||||||
Route::post('model-pricing-import', [ModelPricingController::class, 'import'])->name('model-pricing.import');
|
Route::post('model-pricing-import', [ModelPricingController::class, 'import'])->name('model-pricing.import');
|
||||||
|
Route::get('api/provider-models/{provider}', [ModelPricingController::class, 'getProviderModels'])->name('api.provider-models');
|
||||||
|
|
||||||
|
// Provider Credentials Management (Admin)
|
||||||
|
Route::prefix('admin')->name('admin.')->group(function () {
|
||||||
|
// User Management
|
||||||
|
Route::get('users', [UserManagementController::class, 'index'])
|
||||||
|
->name('users.index');
|
||||||
|
|
||||||
|
// Credentials
|
||||||
|
Route::resource('credentials', CredentialController::class);
|
||||||
|
Route::post('credentials/{credential}/test', [CredentialController::class, 'test'])
|
||||||
|
->name('credentials.test');
|
||||||
|
Route::post('credentials/{credential}/toggle', [CredentialController::class, 'toggle'])
|
||||||
|
->name('credentials.toggle');
|
||||||
|
|
||||||
|
// User Budget & Rate Limit Management
|
||||||
|
Route::get('users/{user}/budget', [UserBudgetController::class, 'show'])
|
||||||
|
->name('users.budget.show');
|
||||||
|
Route::put('users/{user}/budget', [UserBudgetController::class, 'updateBudget'])
|
||||||
|
->name('users.budget.update');
|
||||||
|
Route::put('users/{user}/rate-limit', [UserBudgetController::class, 'updateRateLimit'])
|
||||||
|
->name('users.rate-limit.update');
|
||||||
|
Route::post('users/{user}/rate-limit/reset', [UserBudgetController::class, 'resetRateLimit'])
|
||||||
|
->name('users.rate-limit.reset');
|
||||||
|
Route::post('users/{user}/budget/reset', [UserBudgetController::class, 'resetBudget'])
|
||||||
|
->name('users.budget.reset');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::view('profile', 'profile')
|
Route::view('profile', 'profile')
|
||||||
|
|||||||
144
laravel-app/tests/Unit/Services/AnthropicProviderTest.php
Normal file
144
laravel-app/tests/Unit/Services/AnthropicProviderTest.php
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Services;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use App\Services\LLM\Providers\AnthropicProvider;
|
||||||
|
use App\Models\ModelPricing;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
class AnthropicProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private AnthropicProvider $provider;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->provider = new AnthropicProvider('test-api-key');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_builds_request_correctly_with_system_message(): void
|
||||||
|
{
|
||||||
|
$messages = [
|
||||||
|
['role' => 'system', 'content' => 'You are a helpful assistant'],
|
||||||
|
['role' => 'user', 'content' => 'Hello']
|
||||||
|
];
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
'model' => 'claude-sonnet-4',
|
||||||
|
'temperature' => 0.7,
|
||||||
|
'max_tokens' => 2000
|
||||||
|
];
|
||||||
|
|
||||||
|
$reflection = new \ReflectionClass($this->provider);
|
||||||
|
$method = $reflection->getMethod('buildRequest');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$result = $method->invoke($this->provider, $messages, $options);
|
||||||
|
|
||||||
|
$this->assertEquals('claude-sonnet-4', $result['model']);
|
||||||
|
$this->assertEquals(0.7, $result['temperature']);
|
||||||
|
$this->assertEquals(2000, $result['max_tokens']);
|
||||||
|
$this->assertEquals('You are a helpful assistant', $result['system']);
|
||||||
|
$this->assertCount(1, $result['messages']); // System message extracted
|
||||||
|
$this->assertEquals('user', $result['messages'][0]['role']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_normalizes_response_correctly(): void
|
||||||
|
{
|
||||||
|
$rawResponse = [
|
||||||
|
'id' => 'msg_123',
|
||||||
|
'model' => 'claude-sonnet-4',
|
||||||
|
'content' => [
|
||||||
|
[
|
||||||
|
'type' => 'text',
|
||||||
|
'text' => 'Hello! How can I assist you today?'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'role' => 'assistant',
|
||||||
|
'stop_reason' => 'end_turn',
|
||||||
|
'usage' => [
|
||||||
|
'input_tokens' => 15,
|
||||||
|
'output_tokens' => 25
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$normalized = $this->provider->normalizeResponse($rawResponse);
|
||||||
|
|
||||||
|
$this->assertEquals('msg_123', $normalized['id']);
|
||||||
|
$this->assertEquals('claude-sonnet-4', $normalized['model']);
|
||||||
|
$this->assertEquals('Hello! How can I assist you today?', $normalized['content']);
|
||||||
|
$this->assertEquals('assistant', $normalized['role']);
|
||||||
|
$this->assertEquals('end_turn', $normalized['finish_reason']);
|
||||||
|
$this->assertEquals(15, $normalized['usage']['prompt_tokens']);
|
||||||
|
$this->assertEquals(25, $normalized['usage']['completion_tokens']);
|
||||||
|
$this->assertEquals(40, $normalized['usage']['total_tokens']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_calculates_cost_correctly(): void
|
||||||
|
{
|
||||||
|
// Create pricing in database
|
||||||
|
ModelPricing::create([
|
||||||
|
'provider' => 'anthropic',
|
||||||
|
'model' => 'claude-sonnet-4',
|
||||||
|
'input_price_per_million' => 3.00,
|
||||||
|
'output_price_per_million' => 15.00,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => now()
|
||||||
|
]);
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$cost = $this->provider->calculateCost(1000, 500, 'claude-sonnet-4');
|
||||||
|
|
||||||
|
// Expected: (1000/1M * 3.00) + (500/1M * 15.00) = 0.003 + 0.0075 = 0.0105
|
||||||
|
$this->assertEquals(0.0105, $cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_handles_api_errors(): void
|
||||||
|
{
|
||||||
|
Http::fake([
|
||||||
|
'https://api.anthropic.com/*' => Http::response(['error' => 'Invalid API key'], 401)
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(\App\Exceptions\ProviderException::class);
|
||||||
|
$this->expectExceptionMessage('Invalid API key');
|
||||||
|
|
||||||
|
$this->provider->chatCompletion([
|
||||||
|
['role' => 'user', 'content' => 'test']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_supported_models(): void
|
||||||
|
{
|
||||||
|
$models = $this->provider->getSupportedModels();
|
||||||
|
|
||||||
|
$this->assertIsArray($models);
|
||||||
|
$this->assertContains('claude-opus-4', $models);
|
||||||
|
$this->assertContains('claude-sonnet-4', $models);
|
||||||
|
$this->assertContains('claude-haiku-4', $models);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_handles_multiple_content_blocks(): void
|
||||||
|
{
|
||||||
|
$rawResponse = [
|
||||||
|
'id' => 'msg_456',
|
||||||
|
'model' => 'claude-sonnet-4',
|
||||||
|
'content' => [
|
||||||
|
['type' => 'text', 'text' => 'First part. '],
|
||||||
|
['type' => 'text', 'text' => 'Second part.']
|
||||||
|
],
|
||||||
|
'role' => 'assistant',
|
||||||
|
'stop_reason' => 'end_turn',
|
||||||
|
'usage' => ['input_tokens' => 10, 'output_tokens' => 20]
|
||||||
|
];
|
||||||
|
|
||||||
|
$normalized = $this->provider->normalizeResponse($rawResponse);
|
||||||
|
|
||||||
|
$this->assertEquals('First part. Second part.', $normalized['content']);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
laravel-app/tests/Unit/Services/CostCalculatorTest.php
Normal file
105
laravel-app/tests/Unit/Services/CostCalculatorTest.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Services;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use App\Services\LLM\CostCalculator;
|
||||||
|
use App\Models\ModelPricing;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
class CostCalculatorTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private CostCalculator $calculator;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->calculator = new CostCalculator();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_calculates_cost_correctly(): void
|
||||||
|
{
|
||||||
|
ModelPricing::create([
|
||||||
|
'provider' => 'openai',
|
||||||
|
'model' => 'gpt-4o-mini',
|
||||||
|
'input_price_per_million' => 0.15,
|
||||||
|
'output_price_per_million' => 0.60,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$costs = $this->calculator->calculate('openai', 'gpt-4o-mini', 1000, 500);
|
||||||
|
|
||||||
|
// (1000/1M * 0.15) + (500/1M * 0.60) = 0.00015 + 0.0003 = 0.00045
|
||||||
|
$this->assertEquals(0.00015, $costs['prompt_cost']);
|
||||||
|
$this->assertEquals(0.0003, $costs['completion_cost']);
|
||||||
|
$this->assertEquals(0.00045, $costs['total_cost']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_returns_zero_cost_for_unknown_model(): void
|
||||||
|
{
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$costs = $this->calculator->calculate('unknown', 'unknown-model', 1000, 500);
|
||||||
|
|
||||||
|
$this->assertEquals(0.0, $costs['prompt_cost']);
|
||||||
|
$this->assertEquals(0.0, $costs['completion_cost']);
|
||||||
|
$this->assertEquals(0.0, $costs['total_cost']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_uses_cache_for_pricing(): void
|
||||||
|
{
|
||||||
|
ModelPricing::create([
|
||||||
|
'provider' => 'anthropic',
|
||||||
|
'model' => 'claude-sonnet-4',
|
||||||
|
'input_price_per_million' => 3.00,
|
||||||
|
'output_price_per_million' => 15.00,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
// First call - should query database
|
||||||
|
$costs1 = $this->calculator->calculate('anthropic', 'claude-sonnet-4', 1000, 500);
|
||||||
|
|
||||||
|
// Second call - should use cache
|
||||||
|
$costs2 = $this->calculator->calculate('anthropic', 'claude-sonnet-4', 1000, 500);
|
||||||
|
|
||||||
|
$this->assertEquals($costs1, $costs2);
|
||||||
|
$this->assertTrue(Cache::has('pricing:anthropic:claude-sonnet-4'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_estimate_cost(): void
|
||||||
|
{
|
||||||
|
ModelPricing::create([
|
||||||
|
'provider' => 'openai',
|
||||||
|
'model' => 'gpt-4o',
|
||||||
|
'input_price_per_million' => 2.50,
|
||||||
|
'output_price_per_million' => 10.00,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$estimatedCost = $this->calculator->estimateCost('openai', 'gpt-4o', 2000, 1000);
|
||||||
|
|
||||||
|
// (2000/1M * 2.50) + (1000/1M * 10.00) = 0.005 + 0.01 = 0.015
|
||||||
|
$this->assertEquals(0.015, $estimatedCost);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_clear_cache(): void
|
||||||
|
{
|
||||||
|
Cache::put('pricing:test:model', 'test_data', 3600);
|
||||||
|
|
||||||
|
$this->calculator->clearCache('test', 'model');
|
||||||
|
|
||||||
|
$this->assertFalse(Cache::has('pricing:test:model'));
|
||||||
|
}
|
||||||
|
}
|
||||||
128
laravel-app/tests/Unit/Services/DeepSeekProviderTest.php
Normal file
128
laravel-app/tests/Unit/Services/DeepSeekProviderTest.php
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Services;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use App\Services\LLM\Providers\DeepSeekProvider;
|
||||||
|
use App\Models\ModelPricing;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
class DeepSeekProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private DeepSeekProvider $provider;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->provider = new DeepSeekProvider('test-api-key');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_builds_request_correctly(): void
|
||||||
|
{
|
||||||
|
$messages = [
|
||||||
|
['role' => 'user', 'content' => 'Write a function']
|
||||||
|
];
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
'model' => 'deepseek-coder',
|
||||||
|
'temperature' => 0.5,
|
||||||
|
'max_tokens' => 1500
|
||||||
|
];
|
||||||
|
|
||||||
|
$reflection = new \ReflectionClass($this->provider);
|
||||||
|
$method = $reflection->getMethod('buildRequest');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$result = $method->invoke($this->provider, $messages, $options);
|
||||||
|
|
||||||
|
$this->assertEquals('deepseek-coder', $result['model']);
|
||||||
|
$this->assertEquals(0.5, $result['temperature']);
|
||||||
|
$this->assertEquals(1500, $result['max_tokens']);
|
||||||
|
$this->assertEquals($messages, $result['messages']);
|
||||||
|
$this->assertFalse($result['stream']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_normalizes_response_correctly(): void
|
||||||
|
{
|
||||||
|
$rawResponse = [
|
||||||
|
'id' => 'deepseek-123',
|
||||||
|
'model' => 'deepseek-coder',
|
||||||
|
'choices' => [
|
||||||
|
[
|
||||||
|
'message' => [
|
||||||
|
'role' => 'assistant',
|
||||||
|
'content' => 'def hello_world():\n print("Hello, World!")'
|
||||||
|
],
|
||||||
|
'finish_reason' => 'stop'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'usage' => [
|
||||||
|
'prompt_tokens' => 8,
|
||||||
|
'completion_tokens' => 22,
|
||||||
|
'total_tokens' => 30
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$normalized = $this->provider->normalizeResponse($rawResponse);
|
||||||
|
|
||||||
|
$this->assertEquals('deepseek-123', $normalized['id']);
|
||||||
|
$this->assertEquals('deepseek-coder', $normalized['model']);
|
||||||
|
$this->assertStringContainsString('def hello_world()', $normalized['content']);
|
||||||
|
$this->assertEquals('assistant', $normalized['role']);
|
||||||
|
$this->assertEquals('stop', $normalized['finish_reason']);
|
||||||
|
$this->assertEquals(8, $normalized['usage']['prompt_tokens']);
|
||||||
|
$this->assertEquals(22, $normalized['usage']['completion_tokens']);
|
||||||
|
$this->assertEquals(30, $normalized['usage']['total_tokens']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_calculates_cost_correctly(): void
|
||||||
|
{
|
||||||
|
ModelPricing::updateOrCreate(
|
||||||
|
[
|
||||||
|
'provider' => 'deepseek',
|
||||||
|
'model' => 'deepseek-chat',
|
||||||
|
'effective_from' => now()->toDateString(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'input_price_per_million' => 0.14,
|
||||||
|
'output_price_per_million' => 0.28,
|
||||||
|
'is_active' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$cost = $this->provider->calculateCost(1000, 500, 'deepseek-chat');
|
||||||
|
|
||||||
|
// Expected: (1000/1M * 0.14) + (500/1M * 0.28) = 0.00014 + 0.00014 = 0.00028
|
||||||
|
$this->assertEquals(0.00028, $cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_handles_api_errors(): void
|
||||||
|
{
|
||||||
|
Http::fake([
|
||||||
|
'https://api.deepseek.com/*' => Http::response(['error' => 'Invalid API key'], 401)
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(\App\Exceptions\ProviderException::class);
|
||||||
|
$this->expectExceptionMessage('Invalid API key');
|
||||||
|
|
||||||
|
$this->provider->chatCompletion([
|
||||||
|
['role' => 'user', 'content' => 'test']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_supported_models(): void
|
||||||
|
{
|
||||||
|
$models = $this->provider->getSupportedModels();
|
||||||
|
|
||||||
|
$this->assertIsArray($models);
|
||||||
|
$this->assertContains('deepseek-chat', $models);
|
||||||
|
$this->assertContains('deepseek-coder', $models);
|
||||||
|
$this->assertContains('deepseek-reasoner', $models);
|
||||||
|
}
|
||||||
|
}
|
||||||
147
laravel-app/tests/Unit/Services/GeminiProviderTest.php
Normal file
147
laravel-app/tests/Unit/Services/GeminiProviderTest.php
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Services;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use App\Services\LLM\Providers\GeminiProvider;
|
||||||
|
use App\Models\ModelPricing;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
class GeminiProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private GeminiProvider $provider;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->provider = new GeminiProvider('test-api-key');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_builds_request_correctly(): void
|
||||||
|
{
|
||||||
|
$messages = [
|
||||||
|
['role' => 'user', 'content' => 'Hello, Gemini!']
|
||||||
|
];
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
'model' => 'gemini-pro',
|
||||||
|
'temperature' => 0.9,
|
||||||
|
'max_tokens' => 2000
|
||||||
|
];
|
||||||
|
|
||||||
|
$reflection = new \ReflectionClass($this->provider);
|
||||||
|
$method = $reflection->getMethod('buildRequest');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$result = $method->invoke($this->provider, $messages, $options);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('contents', $result);
|
||||||
|
$this->assertCount(1, $result['contents']);
|
||||||
|
$this->assertEquals('user', $result['contents'][0]['role']);
|
||||||
|
$this->assertEquals('Hello, Gemini!', $result['contents'][0]['parts'][0]['text']);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('generationConfig', $result);
|
||||||
|
$this->assertEquals(0.9, $result['generationConfig']['temperature']);
|
||||||
|
$this->assertEquals(2000, $result['generationConfig']['maxOutputTokens']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_converts_system_messages_to_user(): void
|
||||||
|
{
|
||||||
|
$messages = [
|
||||||
|
['role' => 'system', 'content' => 'You are helpful'],
|
||||||
|
['role' => 'user', 'content' => 'Hello']
|
||||||
|
];
|
||||||
|
|
||||||
|
$reflection = new \ReflectionClass($this->provider);
|
||||||
|
$method = $reflection->getMethod('buildRequest');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$result = $method->invoke($this->provider, $messages, []);
|
||||||
|
|
||||||
|
$this->assertEquals('user', $result['contents'][0]['role']);
|
||||||
|
$this->assertEquals('user', $result['contents'][1]['role']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_normalizes_response_correctly(): void
|
||||||
|
{
|
||||||
|
$rawResponse = [
|
||||||
|
'candidates' => [
|
||||||
|
[
|
||||||
|
'content' => [
|
||||||
|
'parts' => [
|
||||||
|
['text' => 'Hello! How can I help you today?']
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'finishReason' => 'STOP'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'usageMetadata' => [
|
||||||
|
'promptTokenCount' => 8,
|
||||||
|
'candidatesTokenCount' => 15,
|
||||||
|
'totalTokenCount' => 23
|
||||||
|
],
|
||||||
|
'modelVersion' => 'gemini-pro'
|
||||||
|
];
|
||||||
|
|
||||||
|
$normalized = $this->provider->normalizeResponse($rawResponse);
|
||||||
|
|
||||||
|
$this->assertEquals('gemini-pro', $normalized['model']);
|
||||||
|
$this->assertEquals('Hello! How can I help you today?', $normalized['content']);
|
||||||
|
$this->assertEquals('assistant', $normalized['role']);
|
||||||
|
$this->assertEquals('STOP', $normalized['finish_reason']);
|
||||||
|
$this->assertEquals(8, $normalized['usage']['prompt_tokens']);
|
||||||
|
$this->assertEquals(15, $normalized['usage']['completion_tokens']);
|
||||||
|
$this->assertEquals(23, $normalized['usage']['total_tokens']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_calculates_cost_correctly(): void
|
||||||
|
{
|
||||||
|
ModelPricing::updateOrCreate(
|
||||||
|
[
|
||||||
|
'provider' => 'gemini',
|
||||||
|
'model' => 'gemini-pro',
|
||||||
|
'effective_from' => now()->toDateString(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'input_price_per_million' => 0.50,
|
||||||
|
'output_price_per_million' => 1.50,
|
||||||
|
'is_active' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$cost = $this->provider->calculateCost(1000, 500, 'gemini-pro');
|
||||||
|
|
||||||
|
// Expected: (1000/1M * 0.50) + (500/1M * 1.50) = 0.0005 + 0.00075 = 0.00125
|
||||||
|
$this->assertEquals(0.00125, $cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_handles_api_errors(): void
|
||||||
|
{
|
||||||
|
Http::fake([
|
||||||
|
'https://generativelanguage.googleapis.com/*' => Http::response(['error' => 'Invalid API key'], 401)
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(\App\Exceptions\ProviderException::class);
|
||||||
|
$this->expectExceptionMessage('Invalid API key');
|
||||||
|
|
||||||
|
$this->provider->chatCompletion([
|
||||||
|
['role' => 'user', 'content' => 'test']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_supported_models(): void
|
||||||
|
{
|
||||||
|
$models = $this->provider->getSupportedModels();
|
||||||
|
|
||||||
|
$this->assertIsArray($models);
|
||||||
|
$this->assertContains('gemini-pro', $models);
|
||||||
|
$this->assertContains('gemini-1.5-pro', $models);
|
||||||
|
$this->assertContains('gemini-1.5-flash', $models);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
laravel-app/tests/Unit/Services/MistralProviderTest.php
Normal file
128
laravel-app/tests/Unit/Services/MistralProviderTest.php
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Services;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use App\Services\LLM\Providers\MistralProvider;
|
||||||
|
use App\Models\ModelPricing;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
class MistralProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private MistralProvider $provider;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->provider = new MistralProvider('test-api-key');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_builds_request_correctly(): void
|
||||||
|
{
|
||||||
|
$messages = [
|
||||||
|
['role' => 'user', 'content' => 'Hello']
|
||||||
|
];
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
'model' => 'mistral-small-latest',
|
||||||
|
'temperature' => 0.8,
|
||||||
|
'max_tokens' => 1000
|
||||||
|
];
|
||||||
|
|
||||||
|
$reflection = new \ReflectionClass($this->provider);
|
||||||
|
$method = $reflection->getMethod('buildRequest');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$result = $method->invoke($this->provider, $messages, $options);
|
||||||
|
|
||||||
|
$this->assertEquals('mistral-small-latest', $result['model']);
|
||||||
|
$this->assertEquals(0.8, $result['temperature']);
|
||||||
|
$this->assertEquals(1000, $result['max_tokens']);
|
||||||
|
$this->assertEquals($messages, $result['messages']);
|
||||||
|
$this->assertArrayNotHasKey('stream', $result); // stream=false is filtered out
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_normalizes_response_correctly(): void
|
||||||
|
{
|
||||||
|
$rawResponse = [
|
||||||
|
'id' => 'cmpl-123',
|
||||||
|
'model' => 'mistral-small-latest',
|
||||||
|
'choices' => [
|
||||||
|
[
|
||||||
|
'message' => [
|
||||||
|
'role' => 'assistant',
|
||||||
|
'content' => 'Bonjour! Comment puis-je vous aider?'
|
||||||
|
],
|
||||||
|
'finish_reason' => 'stop'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'usage' => [
|
||||||
|
'prompt_tokens' => 12,
|
||||||
|
'completion_tokens' => 18,
|
||||||
|
'total_tokens' => 30
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$normalized = $this->provider->normalizeResponse($rawResponse);
|
||||||
|
|
||||||
|
$this->assertEquals('cmpl-123', $normalized['id']);
|
||||||
|
$this->assertEquals('mistral-small-latest', $normalized['model']);
|
||||||
|
$this->assertEquals('Bonjour! Comment puis-je vous aider?', $normalized['content']);
|
||||||
|
$this->assertEquals('assistant', $normalized['role']);
|
||||||
|
$this->assertEquals('stop', $normalized['finish_reason']);
|
||||||
|
$this->assertEquals(12, $normalized['usage']['prompt_tokens']);
|
||||||
|
$this->assertEquals(18, $normalized['usage']['completion_tokens']);
|
||||||
|
$this->assertEquals(30, $normalized['usage']['total_tokens']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_calculates_cost_correctly(): void
|
||||||
|
{
|
||||||
|
ModelPricing::updateOrCreate(
|
||||||
|
[
|
||||||
|
'provider' => 'mistral',
|
||||||
|
'model' => 'mistral-small-latest',
|
||||||
|
'effective_from' => now()->toDateString(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'input_price_per_million' => 0.20,
|
||||||
|
'output_price_per_million' => 0.60,
|
||||||
|
'is_active' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$cost = $this->provider->calculateCost(1000, 500, 'mistral-small-latest');
|
||||||
|
|
||||||
|
// Expected: (1000/1M * 0.20) + (500/1M * 0.60) = 0.0002 + 0.0003 = 0.0005
|
||||||
|
$this->assertEquals(0.0005, $cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_handles_api_errors(): void
|
||||||
|
{
|
||||||
|
Http::fake([
|
||||||
|
'https://api.mistral.ai/*' => Http::response(['error' => 'Invalid API key'], 401)
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(\App\Exceptions\ProviderException::class);
|
||||||
|
$this->expectExceptionMessage('Invalid API key');
|
||||||
|
|
||||||
|
$this->provider->chatCompletion([
|
||||||
|
['role' => 'user', 'content' => 'test']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_supported_models(): void
|
||||||
|
{
|
||||||
|
$models = $this->provider->getSupportedModels();
|
||||||
|
|
||||||
|
$this->assertIsArray($models);
|
||||||
|
$this->assertContains('mistral-large-latest', $models);
|
||||||
|
$this->assertContains('mistral-small-latest', $models);
|
||||||
|
$this->assertContains('open-mixtral-8x7b', $models);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
laravel-app/tests/Unit/Services/OpenAIProviderTest.php
Normal file
150
laravel-app/tests/Unit/Services/OpenAIProviderTest.php
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Services;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use App\Services\LLM\Providers\OpenAIProvider;
|
||||||
|
use App\Models\ModelPricing;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
class OpenAIProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private OpenAIProvider $provider;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->provider = new OpenAIProvider('test-api-key');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_builds_request_correctly(): void
|
||||||
|
{
|
||||||
|
$messages = [
|
||||||
|
['role' => 'user', 'content' => 'Hello']
|
||||||
|
];
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
'model' => 'gpt-4o-mini',
|
||||||
|
'temperature' => 0.8,
|
||||||
|
'max_tokens' => 1000
|
||||||
|
];
|
||||||
|
|
||||||
|
$reflection = new \ReflectionClass($this->provider);
|
||||||
|
$method = $reflection->getMethod('buildRequest');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$result = $method->invoke($this->provider, $messages, $options);
|
||||||
|
|
||||||
|
$this->assertEquals('gpt-4o-mini', $result['model']);
|
||||||
|
$this->assertEquals(0.8, $result['temperature']);
|
||||||
|
$this->assertEquals(1000, $result['max_tokens']);
|
||||||
|
$this->assertEquals($messages, $result['messages']);
|
||||||
|
$this->assertFalse($result['stream']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_normalizes_response_correctly(): void
|
||||||
|
{
|
||||||
|
$rawResponse = [
|
||||||
|
'id' => 'chatcmpl-123',
|
||||||
|
'model' => 'gpt-4o-mini',
|
||||||
|
'choices' => [
|
||||||
|
[
|
||||||
|
'message' => [
|
||||||
|
'role' => 'assistant',
|
||||||
|
'content' => 'Hello! How can I help you?'
|
||||||
|
],
|
||||||
|
'finish_reason' => 'stop'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'usage' => [
|
||||||
|
'prompt_tokens' => 10,
|
||||||
|
'completion_tokens' => 20,
|
||||||
|
'total_tokens' => 30
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$normalized = $this->provider->normalizeResponse($rawResponse);
|
||||||
|
|
||||||
|
$this->assertEquals('chatcmpl-123', $normalized['id']);
|
||||||
|
$this->assertEquals('gpt-4o-mini', $normalized['model']);
|
||||||
|
$this->assertEquals('Hello! How can I help you?', $normalized['content']);
|
||||||
|
$this->assertEquals('assistant', $normalized['role']);
|
||||||
|
$this->assertEquals('stop', $normalized['finish_reason']);
|
||||||
|
$this->assertEquals(10, $normalized['usage']['prompt_tokens']);
|
||||||
|
$this->assertEquals(20, $normalized['usage']['completion_tokens']);
|
||||||
|
$this->assertEquals(30, $normalized['usage']['total_tokens']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_calculates_cost_correctly(): void
|
||||||
|
{
|
||||||
|
// Create pricing in database
|
||||||
|
ModelPricing::create([
|
||||||
|
'provider' => 'openai',
|
||||||
|
'model' => 'gpt-4o-mini',
|
||||||
|
'input_price_per_million' => 0.15,
|
||||||
|
'output_price_per_million' => 0.60,
|
||||||
|
'is_active' => true,
|
||||||
|
'effective_from' => now()
|
||||||
|
]);
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$cost = $this->provider->calculateCost(1000, 500, 'gpt-4o-mini');
|
||||||
|
|
||||||
|
// Expected: (1000/1M * 0.15) + (500/1M * 0.60) = 0.00015 + 0.0003 = 0.00045
|
||||||
|
$this->assertEquals(0.00045, $cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_handles_api_errors(): void
|
||||||
|
{
|
||||||
|
Http::fake([
|
||||||
|
'https://api.openai.com/*' => Http::response(['error' => 'Invalid API key'], 401)
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(\App\Exceptions\ProviderException::class);
|
||||||
|
$this->expectExceptionMessage('Invalid API key');
|
||||||
|
|
||||||
|
$this->provider->chatCompletion([
|
||||||
|
['role' => 'user', 'content' => 'test']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retries_on_server_error(): void
|
||||||
|
{
|
||||||
|
Http::fake([
|
||||||
|
'https://api.openai.com/*' => Http::sequence()
|
||||||
|
->push(['error' => 'Server error'], 500)
|
||||||
|
->push(['error' => 'Server error'], 500)
|
||||||
|
->push([
|
||||||
|
'id' => 'test-123',
|
||||||
|
'model' => 'gpt-4o-mini',
|
||||||
|
'choices' => [[
|
||||||
|
'message' => ['content' => 'Success', 'role' => 'assistant'],
|
||||||
|
'finish_reason' => 'stop'
|
||||||
|
]],
|
||||||
|
'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 5, 'total_tokens' => 15]
|
||||||
|
], 200)
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->provider->chatCompletion([
|
||||||
|
['role' => 'user', 'content' => 'test']
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('id', $result);
|
||||||
|
$this->assertEquals('test-123', $result['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_supported_models(): void
|
||||||
|
{
|
||||||
|
$models = $this->provider->getSupportedModels();
|
||||||
|
|
||||||
|
$this->assertIsArray($models);
|
||||||
|
$this->assertContains('gpt-4o', $models);
|
||||||
|
$this->assertContains('gpt-4o-mini', $models);
|
||||||
|
$this->assertContains('gpt-3.5-turbo', $models);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
laravel-app/tests/Unit/Services/ProviderFactoryTest.php
Normal file
97
laravel-app/tests/Unit/Services/ProviderFactoryTest.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Services;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use App\Services\LLM\ProviderFactory;
|
||||||
|
use App\Services\LLM\Providers\{
|
||||||
|
OpenAIProvider,
|
||||||
|
AnthropicProvider,
|
||||||
|
MistralProvider,
|
||||||
|
GeminiProvider,
|
||||||
|
DeepSeekProvider
|
||||||
|
};
|
||||||
|
|
||||||
|
class ProviderFactoryTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_creates_openai_provider(): void
|
||||||
|
{
|
||||||
|
$provider = ProviderFactory::create('openai', 'test-key');
|
||||||
|
|
||||||
|
$this->assertInstanceOf(OpenAIProvider::class, $provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_creates_anthropic_provider(): void
|
||||||
|
{
|
||||||
|
$provider = ProviderFactory::create('anthropic', 'test-key');
|
||||||
|
|
||||||
|
$this->assertInstanceOf(AnthropicProvider::class, $provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_creates_mistral_provider(): void
|
||||||
|
{
|
||||||
|
$provider = ProviderFactory::create('mistral', 'test-key');
|
||||||
|
|
||||||
|
$this->assertInstanceOf(MistralProvider::class, $provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_creates_gemini_provider(): void
|
||||||
|
{
|
||||||
|
$provider = ProviderFactory::create('gemini', 'test-key');
|
||||||
|
|
||||||
|
$this->assertInstanceOf(GeminiProvider::class, $provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_creates_deepseek_provider(): void
|
||||||
|
{
|
||||||
|
$provider = ProviderFactory::create('deepseek', 'test-key');
|
||||||
|
|
||||||
|
$this->assertInstanceOf(DeepSeekProvider::class, $provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_throws_exception_for_unknown_provider(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Unknown provider: unknown');
|
||||||
|
|
||||||
|
ProviderFactory::create('unknown', 'test-key');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_is_case_insensitive(): void
|
||||||
|
{
|
||||||
|
$provider1 = ProviderFactory::create('OpenAI', 'test-key');
|
||||||
|
$provider2 = ProviderFactory::create('ANTHROPIC', 'test-key');
|
||||||
|
|
||||||
|
$this->assertInstanceOf(OpenAIProvider::class, $provider1);
|
||||||
|
$this->assertInstanceOf(AnthropicProvider::class, $provider2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_supported_providers(): void
|
||||||
|
{
|
||||||
|
$providers = ProviderFactory::getSupportedProviders();
|
||||||
|
|
||||||
|
$this->assertIsArray($providers);
|
||||||
|
$this->assertContains('openai', $providers);
|
||||||
|
$this->assertContains('anthropic', $providers);
|
||||||
|
$this->assertContains('mistral', $providers);
|
||||||
|
$this->assertContains('gemini', $providers);
|
||||||
|
$this->assertContains('deepseek', $providers);
|
||||||
|
$this->assertCount(5, $providers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_is_supported(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(ProviderFactory::isSupported('openai'));
|
||||||
|
$this->assertTrue(ProviderFactory::isSupported('anthropic'));
|
||||||
|
$this->assertTrue(ProviderFactory::isSupported('mistral'));
|
||||||
|
$this->assertTrue(ProviderFactory::isSupported('gemini'));
|
||||||
|
$this->assertTrue(ProviderFactory::isSupported('deepseek'));
|
||||||
|
$this->assertFalse(ProviderFactory::isSupported('unknown'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_is_supported_case_insensitive(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(ProviderFactory::isSupported('OpenAI'));
|
||||||
|
$this->assertTrue(ProviderFactory::isSupported('ANTHROPIC'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
|||||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install PHP extensions
|
# Install PHP extensions
|
||||||
RUN docker-php-ext-install pdo pdo_pgsql pgsql mbstring exif pcntl bcmath gd
|
RUN docker-php-ext-install pdo pdo_mysql pdo_pgsql pgsql mysqli mbstring exif pcntl bcmath gd
|
||||||
|
|
||||||
# Get latest Composer
|
# Get latest Composer
|
||||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||||
|
|||||||
190
setup-laravel.sh
190
setup-laravel.sh
@@ -1,190 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🚀 Any-LLM Laravel Setup"
|
|
||||||
echo "========================"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Farben für Output
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# Check if Docker is running
|
|
||||||
if ! docker info > /dev/null 2>&1; then
|
|
||||||
echo -e "${RED}❌ Docker ist nicht gestartet!${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e "${BLUE}📦 Schritt 1: Laravel Projekt erstellen${NC}"
|
|
||||||
if [ ! -d "laravel-app" ]; then
|
|
||||||
echo "Laravel wird in ./laravel-app installiert..."
|
|
||||||
docker run --rm -v $(pwd):/app composer create-project laravel/laravel laravel-app
|
|
||||||
echo -e "${GREEN}✅ Laravel installiert${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${GREEN}✅ Laravel-App existiert bereits${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}📦 Schritt 2: Laravel Packages installieren${NC}"
|
|
||||||
cd laravel-app
|
|
||||||
|
|
||||||
# Composer packages installieren
|
|
||||||
echo "Installiere Livewire und Breeze..."
|
|
||||||
docker run --rm -v $(pwd):/app composer require livewire/livewire
|
|
||||||
docker run --rm -v $(pwd):/app composer require laravel/breeze --dev
|
|
||||||
|
|
||||||
echo -e "${GREEN}✅ Packages installiert${NC}"
|
|
||||||
|
|
||||||
# Zurück zum Hauptverzeichnis
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}📦 Schritt 3: Docker Container starten${NC}"
|
|
||||||
docker compose up -d --build
|
|
||||||
|
|
||||||
echo "Warte auf Container..."
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
echo -e "${GREEN}✅ Container gestartet${NC}"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}📦 Schritt 4: Laravel konfigurieren${NC}"
|
|
||||||
|
|
||||||
# Breeze installieren
|
|
||||||
echo "Installiere Laravel Breeze..."
|
|
||||||
docker compose exec laravel php artisan breeze:install livewire --dark
|
|
||||||
|
|
||||||
# APP_KEY generieren
|
|
||||||
echo "Generiere APP_KEY..."
|
|
||||||
docker compose exec laravel php artisan key:generate
|
|
||||||
|
|
||||||
# Storage Link erstellen
|
|
||||||
echo "Erstelle Storage Link..."
|
|
||||||
docker compose exec laravel php artisan storage:link
|
|
||||||
|
|
||||||
echo -e "${GREEN}✅ Laravel konfiguriert${NC}"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}📦 Schritt 5: Admin Migration erstellen${NC}"
|
|
||||||
|
|
||||||
# Admin Migration erstellen
|
|
||||||
docker compose exec laravel php artisan make:migration create_admins_table
|
|
||||||
|
|
||||||
# Migration File Content erstellen
|
|
||||||
MIGRATION_FILE=$(docker compose exec laravel bash -c "ls -t database/migrations/*create_admins_table.php | head -1" | tr -d '\r')
|
|
||||||
|
|
||||||
docker compose exec laravel bash -c "cat > $MIGRATION_FILE << 'EOF'
|
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('admins', function (Blueprint $table) {
|
|
||||||
\$table->id();
|
|
||||||
\$table->string('name');
|
|
||||||
\$table->string('email')->unique();
|
|
||||||
\$table->timestamp('email_verified_at')->nullable();
|
|
||||||
\$table->string('password');
|
|
||||||
\$table->rememberToken();
|
|
||||||
\$table->timestamps();
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
|
||||||
\$table->string('email')->primary();
|
|
||||||
\$table->string('token');
|
|
||||||
\$table->timestamp('created_at')->nullable();
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('sessions', function (Blueprint $table) {
|
|
||||||
\$table->string('id')->primary();
|
|
||||||
\$table->foreignId('user_id')->nullable()->index();
|
|
||||||
\$table->string('ip_address', 45)->nullable();
|
|
||||||
\$table->text('user_agent')->nullable();
|
|
||||||
\$table->longText('payload');
|
|
||||||
\$table->integer('last_activity')->index();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('admins');
|
|
||||||
Schema::dropIfExists('password_reset_tokens');
|
|
||||||
Schema::dropIfExists('sessions');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
EOF"
|
|
||||||
|
|
||||||
# Migration ausführen
|
|
||||||
echo "Führe Migrationen aus..."
|
|
||||||
docker compose exec laravel php artisan migrate --force
|
|
||||||
|
|
||||||
echo -e "${GREEN}✅ Migrationen abgeschlossen${NC}"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}📦 Schritt 6: Admin User erstellen${NC}"
|
|
||||||
|
|
||||||
# Admin User erstellen
|
|
||||||
docker compose exec laravel php artisan tinker --execute="
|
|
||||||
\App\Models\User::create([
|
|
||||||
'name' => 'Admin',
|
|
||||||
'email' => 'admin@example.com',
|
|
||||||
'password' => bcrypt('password123')
|
|
||||||
]);
|
|
||||||
echo 'Admin User erstellt: admin@example.com / password123';
|
|
||||||
"
|
|
||||||
|
|
||||||
echo -e "${GREEN}✅ Admin User erstellt${NC}"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}📦 Schritt 7: Assets kompilieren${NC}"
|
|
||||||
|
|
||||||
# NPM installieren und Assets kompilieren
|
|
||||||
echo "Installiere NPM Packages..."
|
|
||||||
docker compose exec laravel npm install
|
|
||||||
|
|
||||||
echo "Kompiliere Assets..."
|
|
||||||
docker compose exec laravel npm run build
|
|
||||||
|
|
||||||
echo -e "${GREEN}✅ Assets kompiliert${NC}"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo -e "${GREEN}✅ Setup abgeschlossen!${NC}"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
echo "📍 URLs:"
|
|
||||||
echo " - Laravel Admin: http://localhost:80"
|
|
||||||
echo " - Gateway API: http://localhost:8000"
|
|
||||||
echo " - Gateway Tester: http://localhost:8080"
|
|
||||||
echo " - Adminer (DB): http://localhost:8081"
|
|
||||||
echo ""
|
|
||||||
echo "🔐 Admin Login:"
|
|
||||||
echo " Email: admin@example.com"
|
|
||||||
echo " Password: password123"
|
|
||||||
echo ""
|
|
||||||
echo "🗄️ Adminer Login:"
|
|
||||||
echo " System: PostgreSQL"
|
|
||||||
echo " Server: postgres"
|
|
||||||
echo " Username: gateway"
|
|
||||||
echo " Password: gateway"
|
|
||||||
echo " Database: gateway"
|
|
||||||
echo ""
|
|
||||||
echo "📝 Nächste Schritte:"
|
|
||||||
echo " 1. Öffne http://localhost:80 im Browser"
|
|
||||||
echo " 2. Melde dich mit admin@example.com / password123 an"
|
|
||||||
echo " 3. Beginne mit der Entwicklung!"
|
|
||||||
echo ""
|
|
||||||
echo "🛑 Container stoppen:"
|
|
||||||
echo " docker compose down"
|
|
||||||
echo ""
|
|
||||||
echo "🔄 Container neu starten:"
|
|
||||||
echo " docker compose up -d"
|
|
||||||
echo ""
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name localhost;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ =404;
|
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Proxy für Gateway-Anfragen (um CORS zu vermeiden)
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://gateway:8000/;
|
|
||||||
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;
|
|
||||||
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;
|
|
||||||
|
|
||||||
# CORS headers
|
|
||||||
add_header Access-Control-Allow-Origin * always;
|
|
||||||
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS' always;
|
|
||||||
add_header Access-Control-Allow-Headers 'Content-Type, Authorization, X-Master-Key' always;
|
|
||||||
|
|
||||||
if ($request_method = 'OPTIONS') {
|
|
||||||
return 204;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
428
web/index.html
428
web/index.html
@@ -1,428 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Any-LLM Gateway Tester</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: white;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
font-size: 2.5em;
|
|
||||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 25px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
input, select, textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
border: 2px solid #e0e0e0;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: border-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus, select:focus, textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
min-height: 100px;
|
|
||||||
resize: vertical;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 12px 30px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
button:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.response-box {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-left: 4px solid #667eea;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-top: 20px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
background: #fee;
|
|
||||||
border-left-color: #dc3545;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success {
|
|
||||||
background: #d4edda;
|
|
||||||
border-left-color: #28a745;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
background: #e7f3ff;
|
|
||||||
border-left: 4px solid #2196F3;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info h3 {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: #1976D2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
background: #f4f4f4;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>🚀 Any-LLM Gateway Tester</h1>
|
|
||||||
|
|
||||||
<div class="info">
|
|
||||||
<h3>ℹ️ Gateway Info</h3>
|
|
||||||
<p><strong>Gateway URL:</strong> /api</p>
|
|
||||||
<p><strong>Master Key:</strong> <code>bdab4b...bcd</code> (aus config.yml)</p>
|
|
||||||
<p><strong>Virtual Key für test-user-1:</strong> <code>gw-H9xo...ziAQ</code></p>
|
|
||||||
<p><strong>Authentifizierung:</strong> X-AnyLLM-Key: Bearer KEY</p>
|
|
||||||
<p style="margin-top: 10px; font-size: 14px;"><strong>Hinweis:</strong> Anthropic erfordert Virtual Keys! Master Key funktioniert nur mit OpenAI.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- User Management -->
|
|
||||||
<div class="card">
|
|
||||||
<h2>👤 User Management</h2>
|
|
||||||
<p style="margin-bottom: 15px;">Erstelle zuerst einen User, um Requests zu tracken.</p>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="userId">User ID:</label>
|
|
||||||
<input type="text" id="userId" value="test-user-1" placeholder="z.B. user-123">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="userAlias">Alias (optional):</label>
|
|
||||||
<input type="text" id="userAlias" value="Test User" placeholder="z.B. Bob">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onclick="createUser()" id="createUserBtn">User erstellen</button>
|
|
||||||
<button onclick="getUser()" id="getUserBtn">User abrufen</button>
|
|
||||||
|
|
||||||
<div id="userResponse" style="display: none;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chat Completion Test -->
|
|
||||||
<div class="card">
|
|
||||||
<h2>💬 Chat Completion Test</h2>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="provider">Provider:</label>
|
|
||||||
<select id="provider">
|
|
||||||
<option value="openai">OpenAI</option>
|
|
||||||
<option value="anthropic">Anthropic</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="model">Modell:</label>
|
|
||||||
<select id="model">
|
|
||||||
<option value="gpt-4o-mini">gpt-4o-mini (OpenAI)</option>
|
|
||||||
<option value="gpt-4o">gpt-4o (OpenAI)</option>
|
|
||||||
<option value="claude-3-5-sonnet-20241022">claude-3-5-sonnet (Anthropic)</option>
|
|
||||||
<option value="claude-3-5-haiku-20241022">claude-3-5-haiku (Anthropic)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="chatUserId">User ID für Request:</label>
|
|
||||||
<input type="text" id="chatUserId" value="test-user-1" placeholder="User ID aus User Management">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="message">Deine Nachricht:</label>
|
|
||||||
<textarea id="message" placeholder="Schreibe eine Nachricht...">Hallo! Kannst du mir in einem Satz sagen, wer du bist?</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="streaming"> Streaming aktivieren
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onclick="sendMessage()" id="sendBtn">Nachricht senden</button>
|
|
||||||
|
|
||||||
<div id="response" style="display: none;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const GATEWAY_URL = '/api';
|
|
||||||
const MASTER_KEY = 'bdab4b5261d6e6ed7173c999ababd7c66066d76d3a06c8506a880ecdcfb41bcd';
|
|
||||||
const VIRTUAL_KEY = 'gw-H9xoOa9YIPAU50DaRzIz9-aXW1QtnZvZg3m48hLn1F66-QvI_qjMZh12f0fWziAQ'; // Virtual Key für test-user-1
|
|
||||||
|
|
||||||
// User Management Functions
|
|
||||||
async function createUser() {
|
|
||||||
const userId = document.getElementById('userId').value;
|
|
||||||
const userAlias = document.getElementById('userAlias').value;
|
|
||||||
const responseDiv = document.getElementById('userResponse');
|
|
||||||
const createBtn = document.getElementById('createUserBtn');
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
alert('Bitte User ID eingeben!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
responseDiv.style.display = 'block';
|
|
||||||
responseDiv.className = 'response-box loading';
|
|
||||||
responseDiv.textContent = 'Erstelle User...';
|
|
||||||
createBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${GATEWAY_URL}/v1/users`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-AnyLLM-Key': `Bearer ${MASTER_KEY}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
user_id: userId,
|
|
||||||
alias: userAlias || undefined
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
responseDiv.className = 'response-box success';
|
|
||||||
responseDiv.textContent = `✅ User erstellt!\n\nUser ID: ${data.user_id}\nAlias: ${data.alias || '-'}\nErstellt: ${new Date(data.created_at).toLocaleString('de-DE')}`;
|
|
||||||
} catch (error) {
|
|
||||||
responseDiv.className = 'response-box error';
|
|
||||||
responseDiv.textContent = `Fehler: ${error.message}`;
|
|
||||||
} finally {
|
|
||||||
createBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getUser() {
|
|
||||||
const userId = document.getElementById('userId').value;
|
|
||||||
const responseDiv = document.getElementById('userResponse');
|
|
||||||
const getBtn = document.getElementById('getUserBtn');
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
alert('Bitte User ID eingeben!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
responseDiv.style.display = 'block';
|
|
||||||
responseDiv.className = 'response-box loading';
|
|
||||||
responseDiv.textContent = 'Lade User-Daten...';
|
|
||||||
getBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${GATEWAY_URL}/v1/users/${userId}`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'X-AnyLLM-Key': `Bearer ${MASTER_KEY}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
responseDiv.className = 'response-box success';
|
|
||||||
responseDiv.textContent = `📊 User Info:\n\nUser ID: ${data.user_id}\nAlias: ${data.alias || '-'}\nSpend: $${data.spend}\nBudget: ${data.budget_id || 'Keine Budget-Limits'}\nBlocked: ${data.blocked ? '❌ Ja' : '✅ Nein'}`;
|
|
||||||
} catch (error) {
|
|
||||||
responseDiv.className = 'response-box error';
|
|
||||||
responseDiv.textContent = `Fehler: ${error.message}`;
|
|
||||||
} finally {
|
|
||||||
getBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chat Completion Function
|
|
||||||
async function sendMessage() {
|
|
||||||
const provider = document.getElementById('provider').value;
|
|
||||||
const model = document.getElementById('model').value;
|
|
||||||
const message = document.getElementById('message').value;
|
|
||||||
const chatUserId = document.getElementById('chatUserId').value;
|
|
||||||
const streaming = document.getElementById('streaming').checked;
|
|
||||||
const responseDiv = document.getElementById('response');
|
|
||||||
const sendBtn = document.getElementById('sendBtn');
|
|
||||||
|
|
||||||
if (!chatUserId) {
|
|
||||||
alert('Bitte User ID eingeben!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
responseDiv.style.display = 'block';
|
|
||||||
responseDiv.className = 'response-box loading';
|
|
||||||
responseDiv.textContent = 'Sende Anfrage...';
|
|
||||||
sendBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Wähle den richtigen API Key basierend auf dem Provider
|
|
||||||
// Anthropic erfordert Virtual Keys, da der 'user' Parameter nicht unterstützt wird
|
|
||||||
const apiKey = provider === 'anthropic' ? VIRTUAL_KEY : MASTER_KEY;
|
|
||||||
|
|
||||||
// Anthropic unterstützt den 'user' Parameter nicht
|
|
||||||
const requestBody = {
|
|
||||||
model: `${provider}:${model}`,
|
|
||||||
messages: [
|
|
||||||
{ role: 'user', content: message }
|
|
||||||
],
|
|
||||||
stream: streaming
|
|
||||||
};
|
|
||||||
|
|
||||||
// Nur für OpenAI den user Parameter hinzufügen
|
|
||||||
if (provider === 'openai') {
|
|
||||||
requestBody.user = chatUserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${GATEWAY_URL}/v1/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-AnyLLM-Key': `Bearer ${apiKey}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (streaming) {
|
|
||||||
responseDiv.textContent = '';
|
|
||||||
responseDiv.className = 'response-box';
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
const chunk = decoder.decode(value);
|
|
||||||
const lines = chunk.split('\n').filter(line => line.trim() !== '');
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('data: ')) {
|
|
||||||
const data = line.slice(6);
|
|
||||||
if (data === '[DONE]') continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(data);
|
|
||||||
const content = json.choices[0]?.delta?.content || '';
|
|
||||||
responseDiv.textContent += content;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Parse error:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
responseDiv.className = 'response-box success';
|
|
||||||
} else {
|
|
||||||
const data = await response.json();
|
|
||||||
responseDiv.className = 'response-box success';
|
|
||||||
responseDiv.textContent = data.choices[0].message.content;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
responseDiv.className = 'response-box error';
|
|
||||||
responseDiv.textContent = `Fehler: ${error.message}`;
|
|
||||||
} finally {
|
|
||||||
sendBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user