Add complete Laravel LLM Gateway implementation

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

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

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

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

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

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

View File

@@ -0,0 +1,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);
}
}

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

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

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

View File

@@ -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!');
}
}

View File

@@ -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'));
}
}

View File

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

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

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

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

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

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

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

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LlmRequest extends Model
{
protected $fillable = [
'user_id',
'provider',
'model',
'request_payload',
'response_payload',
'prompt_tokens',
'completion_tokens',
'total_tokens',
'response_time_ms',
'prompt_cost',
'completion_cost',
'total_cost',
'status',
'error_message',
'http_status',
'ip_address',
'user_agent',
'request_id',
];
protected $casts = [
'request_payload' => 'array',
'response_payload' => 'array',
'prompt_tokens' => 'integer',
'completion_tokens' => 'integer',
'total_tokens' => 'integer',
'response_time_ms' => 'integer',
'prompt_cost' => 'decimal:6',
'completion_cost' => 'decimal:6',
'total_cost' => 'decimal:6',
'http_status' => 'integer',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isSuccess(): bool
{
return $this->status === 'success';
}
public function isFailed(): bool
{
return $this->status === 'failed';
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RateLimit extends Model
{
protected $fillable = [
'user_id',
'requests_per_minute',
'requests_per_hour',
'requests_per_day',
'current_minute_count',
'current_hour_count',
'current_day_count',
'minute_started_at',
'hour_started_at',
'day_started_at',
'is_rate_limited',
'rate_limit_expires_at',
];
protected $casts = [
'requests_per_minute' => 'integer',
'requests_per_hour' => 'integer',
'requests_per_day' => 'integer',
'current_minute_count' => 'integer',
'current_hour_count' => 'integer',
'current_day_count' => 'integer',
'minute_started_at' => 'datetime',
'hour_started_at' => 'datetime',
'day_started_at' => 'datetime',
'is_rate_limited' => 'boolean',
'rate_limit_expires_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isMinuteLimitExceeded(): bool
{
if ($this->minute_started_at->lt(now()->subMinute())) {
return false; // Period expired, should be reset
}
return $this->current_minute_count >= $this->requests_per_minute;
}
public function isHourLimitExceeded(): bool
{
if ($this->hour_started_at->lt(now()->subHour())) {
return false; // Period expired, should be reset
}
return $this->current_hour_count >= $this->requests_per_hour;
}
public function isDayLimitExceeded(): bool
{
if ($this->day_started_at->lt(now()->subDay())) {
return false; // Period expired, should be reset
}
return $this->current_day_count >= $this->requests_per_day;
}
public function isAnyLimitExceeded(): bool
{
return $this->isMinuteLimitExceeded()
|| $this->isHourLimitExceeded()
|| $this->isDayLimitExceeded();
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserBudget extends Model
{
protected $fillable = [
'user_id',
'monthly_limit',
'daily_limit',
'current_month_spending',
'current_day_spending',
'month_started_at',
'day_started_at',
'alert_threshold_percentage',
'last_alert_sent_at',
'is_budget_exceeded',
'is_active',
];
protected $casts = [
'monthly_limit' => 'decimal:2',
'daily_limit' => 'decimal:2',
'current_month_spending' => 'decimal:2',
'current_day_spending' => 'decimal:2',
'month_started_at' => 'date',
'day_started_at' => 'date',
'alert_threshold_percentage' => 'integer',
'last_alert_sent_at' => 'datetime',
'is_budget_exceeded' => 'boolean',
'is_active' => 'boolean',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function getRemainingMonthlyBudget(): float
{
return max(0, $this->monthly_limit - $this->current_month_spending);
}
public function getRemainingDailyBudget(): ?float
{
if (!$this->daily_limit) {
return null;
}
return max(0, $this->daily_limit - $this->current_day_spending);
}
public function getMonthlyUsagePercentage(): float
{
if ($this->monthly_limit == 0) {
return 0;
}
return ($this->current_month_spending / $this->monthly_limit) * 100;
}
public function shouldSendAlert(): bool
{
$percentage = $this->getMonthlyUsagePercentage();
return $percentage >= $this->alert_threshold_percentage
&& (!$this->last_alert_sent_at || $this->last_alert_sent_at->lt(now()->subHours(24)));
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Crypt;
class UserProviderCredential extends Model
{
protected $fillable = [
'user_id',
'provider',
'api_key',
'organization_id',
'is_active',
'last_used_at',
];
protected $hidden = [
'api_key',
];
protected $casts = [
'is_active' => 'boolean',
'last_used_at' => 'datetime',
];
// Automatic encryption when setting
public function setApiKeyAttribute($value): void
{
$this->attributes['api_key'] = Crypt::encryptString($value);
}
// Automatic decryption when getting
public function getApiKeyAttribute($value): string
{
return Crypt::decryptString($value);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function markAsUsed(): void
{
$this->update(['last_used_at' => now()]);
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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