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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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')
|
||||
]);
|
||||
}
|
||||
}
|
||||
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';
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user