Add complete Laravel LLM Gateway implementation

Core Features:
- Multi-provider support (OpenAI, Anthropic, DeepSeek, Gemini, Mistral)
- Provider service architecture with abstract base class
- Dynamic model discovery from provider APIs
- Encrypted per-user provider credentials storage

Admin Interface:
- Complete admin panel with Livewire components
- User management with CRUD operations
- API key management with testing capabilities
- Budget system with limits and reset schedules
- Usage logs with filtering and CSV export
- Model pricing management with cost calculator
- Dashboard with Chart.js visualizations

Database Schema:
- MariaDB migrations for all tables
- User provider credentials (encrypted)
- LLM request logging
- Budget tracking and rate limiting
- Model pricing configuration

API Implementation:
- OpenAI-compatible endpoints
- Budget checking middleware
- Rate limit enforcement
- Request logging jobs
- Cost calculation service

Testing:
- Unit tests for all provider services
- Provider factory tests
- Cost calculator tests

Documentation:
- Admin user seeder
- Model pricing seeder
- Configuration files
This commit is contained in:
wtrinkl
2025-11-18 22:18:36 +01:00
parent bef36c7ca2
commit 6573e15ba4
60 changed files with 5991 additions and 0 deletions

View 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';
}
}

View 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();
}
}

View 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)));
}
}

View 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()]);
}
}