diff --git a/laravel-app/app/Exceptions/InsufficientBudgetException.php b/laravel-app/app/Exceptions/InsufficientBudgetException.php new file mode 100644 index 0000000..5912fab --- /dev/null +++ b/laravel-app/app/Exceptions/InsufficientBudgetException.php @@ -0,0 +1,27 @@ +json([ + 'success' => false, + 'error' => 'budget_exceeded', + 'message' => $this->getMessage(), + ], $this->code); + } +} diff --git a/laravel-app/app/Exceptions/ProviderException.php b/laravel-app/app/Exceptions/ProviderException.php new file mode 100644 index 0000000..98eaa98 --- /dev/null +++ b/laravel-app/app/Exceptions/ProviderException.php @@ -0,0 +1,25 @@ +json([ + 'success' => false, + 'error' => 'provider_error', + 'message' => $this->getMessage(), + ], $this->code); + } +} diff --git a/laravel-app/app/Exceptions/RateLimitExceededException.php b/laravel-app/app/Exceptions/RateLimitExceededException.php new file mode 100644 index 0000000..2ccb8f3 --- /dev/null +++ b/laravel-app/app/Exceptions/RateLimitExceededException.php @@ -0,0 +1,27 @@ +json([ + 'success' => false, + 'error' => 'rate_limit_exceeded', + 'message' => $this->getMessage(), + ], $this->code); + } +} diff --git a/laravel-app/app/Http/Controllers/Admin/CredentialController.php b/laravel-app/app/Http/Controllers/Admin/CredentialController.php new file mode 100644 index 0000000..e0622fa --- /dev/null +++ b/laravel-app/app/Http/Controllers/Admin/CredentialController.php @@ -0,0 +1,478 @@ +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() + ]; + } + } +} diff --git a/laravel-app/app/Http/Controllers/Admin/UserBudgetController.php b/laravel-app/app/Http/Controllers/Admin/UserBudgetController.php new file mode 100644 index 0000000..c789b44 --- /dev/null +++ b/laravel-app/app/Http/Controllers/Admin/UserBudgetController.php @@ -0,0 +1,95 @@ +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!'); + } +} diff --git a/laravel-app/app/Http/Controllers/Admin/UserManagementController.php b/laravel-app/app/Http/Controllers/Admin/UserManagementController.php new file mode 100644 index 0000000..fee4637 --- /dev/null +++ b/laravel-app/app/Http/Controllers/Admin/UserManagementController.php @@ -0,0 +1,36 @@ +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')); + } +} diff --git a/laravel-app/app/Http/Controllers/Api/ChatCompletionController.php b/laravel-app/app/Http/Controllers/Api/ChatCompletionController.php new file mode 100644 index 0000000..e74cdc3 --- /dev/null +++ b/laravel-app/app/Http/Controllers/Api/ChatCompletionController.php @@ -0,0 +1,83 @@ +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); + } + } +} diff --git a/laravel-app/app/Http/Middleware/CheckBudget.php b/laravel-app/app/Http/Middleware/CheckBudget.php new file mode 100644 index 0000000..95d3439 --- /dev/null +++ b/laravel-app/app/Http/Middleware/CheckBudget.php @@ -0,0 +1,33 @@ +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); + } +} diff --git a/laravel-app/app/Http/Middleware/CheckRateLimit.php b/laravel-app/app/Http/Middleware/CheckRateLimit.php new file mode 100644 index 0000000..9d65852 --- /dev/null +++ b/laravel-app/app/Http/Middleware/CheckRateLimit.php @@ -0,0 +1,35 @@ +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); + } +} diff --git a/laravel-app/app/Http/Requests/ChatCompletionRequest.php b/laravel-app/app/Http/Requests/ChatCompletionRequest.php new file mode 100644 index 0000000..f2db834 --- /dev/null +++ b/laravel-app/app/Http/Requests/ChatCompletionRequest.php @@ -0,0 +1,58 @@ + ['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', + ]; + } +} diff --git a/laravel-app/app/Jobs/LogLlmRequest.php b/laravel-app/app/Jobs/LogLlmRequest.php new file mode 100644 index 0000000..5e38f65 --- /dev/null +++ b/laravel-app/app/Jobs/LogLlmRequest.php @@ -0,0 +1,88 @@ + $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(), + ]); + } +} diff --git a/laravel-app/app/Jobs/ResetDailyBudgets.php b/laravel-app/app/Jobs/ResetDailyBudgets.php new file mode 100644 index 0000000..2fd72bb --- /dev/null +++ b/laravel-app/app/Jobs/ResetDailyBudgets.php @@ -0,0 +1,45 @@ +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() + ]); + } +} diff --git a/laravel-app/app/Jobs/ResetMonthlyBudgets.php b/laravel-app/app/Jobs/ResetMonthlyBudgets.php new file mode 100644 index 0000000..4155f02 --- /dev/null +++ b/laravel-app/app/Jobs/ResetMonthlyBudgets.php @@ -0,0 +1,52 @@ +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') + ]); + } +} diff --git a/laravel-app/app/Models/LlmRequest.php b/laravel-app/app/Models/LlmRequest.php new file mode 100644 index 0000000..a494ba6 --- /dev/null +++ b/laravel-app/app/Models/LlmRequest.php @@ -0,0 +1,58 @@ + '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'; + } +} diff --git a/laravel-app/app/Models/RateLimit.php b/laravel-app/app/Models/RateLimit.php new file mode 100644 index 0000000..1e15be1 --- /dev/null +++ b/laravel-app/app/Models/RateLimit.php @@ -0,0 +1,74 @@ + '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(); + } +} diff --git a/laravel-app/app/Models/UserBudget.php b/laravel-app/app/Models/UserBudget.php new file mode 100644 index 0000000..209446e --- /dev/null +++ b/laravel-app/app/Models/UserBudget.php @@ -0,0 +1,69 @@ + '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))); + } +} diff --git a/laravel-app/app/Models/UserProviderCredential.php b/laravel-app/app/Models/UserProviderCredential.php new file mode 100644 index 0000000..f721c6d --- /dev/null +++ b/laravel-app/app/Models/UserProviderCredential.php @@ -0,0 +1,50 @@ + '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()]); + } +} diff --git a/laravel-app/app/Services/Budget/BudgetChecker.php b/laravel-app/app/Services/Budget/BudgetChecker.php new file mode 100644 index 0000000..409afae --- /dev/null +++ b/laravel-app/app/Services/Budget/BudgetChecker.php @@ -0,0 +1,213 @@ +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, + ]; + } +} diff --git a/laravel-app/app/Services/LLM/Contracts/ProviderInterface.php b/laravel-app/app/Services/LLM/Contracts/ProviderInterface.php new file mode 100644 index 0000000..639cc44 --- /dev/null +++ b/laravel-app/app/Services/LLM/Contracts/ProviderInterface.php @@ -0,0 +1,48 @@ +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(); + } +} diff --git a/laravel-app/app/Services/LLM/GatewayService.php b/laravel-app/app/Services/LLM/GatewayService.php new file mode 100644 index 0000000..3881b8e --- /dev/null +++ b/laravel-app/app/Services/LLM/GatewayService.php @@ -0,0 +1,172 @@ +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()]); + } + } + } +} diff --git a/laravel-app/app/Services/LLM/ProviderFactory.php b/laravel-app/app/Services/LLM/ProviderFactory.php new file mode 100644 index 0000000..bda8f5a --- /dev/null +++ b/laravel-app/app/Services/LLM/ProviderFactory.php @@ -0,0 +1,62 @@ + 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()); + } +} diff --git a/laravel-app/app/Services/LLM/Providers/AbstractProvider.php b/laravel-app/app/Services/LLM/Providers/AbstractProvider.php new file mode 100644 index 0000000..aa8376a --- /dev/null +++ b/laravel-app/app/Services/LLM/Providers/AbstractProvider.php @@ -0,0 +1,103 @@ +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; + } + } +} diff --git a/laravel-app/app/Services/LLM/Providers/AnthropicProvider.php b/laravel-app/app/Services/LLM/Providers/AnthropicProvider.php new file mode 100644 index 0000000..be2882d --- /dev/null +++ b/laravel-app/app/Services/LLM/Providers/AnthropicProvider.php @@ -0,0 +1,113 @@ + $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', + ]; + } +} diff --git a/laravel-app/app/Services/LLM/Providers/DeepSeekProvider.php b/laravel-app/app/Services/LLM/Providers/DeepSeekProvider.php new file mode 100644 index 0000000..13b0ed3 --- /dev/null +++ b/laravel-app/app/Services/LLM/Providers/DeepSeekProvider.php @@ -0,0 +1,87 @@ + $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', + ]; + } +} diff --git a/laravel-app/app/Services/LLM/Providers/GeminiProvider.php b/laravel-app/app/Services/LLM/Providers/GeminiProvider.php new file mode 100644 index 0000000..6d7d5da --- /dev/null +++ b/laravel-app/app/Services/LLM/Providers/GeminiProvider.php @@ -0,0 +1,132 @@ + $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', + ]; + } +} diff --git a/laravel-app/app/Services/LLM/Providers/MistralProvider.php b/laravel-app/app/Services/LLM/Providers/MistralProvider.php new file mode 100644 index 0000000..cd68105 --- /dev/null +++ b/laravel-app/app/Services/LLM/Providers/MistralProvider.php @@ -0,0 +1,90 @@ + $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', + ]; + } +} diff --git a/laravel-app/app/Services/LLM/Providers/OpenAIProvider.php b/laravel-app/app/Services/LLM/Providers/OpenAIProvider.php new file mode 100644 index 0000000..d7ddddd --- /dev/null +++ b/laravel-app/app/Services/LLM/Providers/OpenAIProvider.php @@ -0,0 +1,89 @@ + $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', + ]; + } +} diff --git a/laravel-app/app/Services/LLM/RequestLogger.php b/laravel-app/app/Services/LLM/RequestLogger.php new file mode 100644 index 0000000..80ca58d --- /dev/null +++ b/laravel-app/app/Services/LLM/RequestLogger.php @@ -0,0 +1,96 @@ +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); + } +} diff --git a/laravel-app/app/Services/RateLimit/RateLimitChecker.php b/laravel-app/app/Services/RateLimit/RateLimitChecker.php new file mode 100644 index 0000000..5035ec3 --- /dev/null +++ b/laravel-app/app/Services/RateLimit/RateLimitChecker.php @@ -0,0 +1,240 @@ +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]); + } +} diff --git a/laravel-app/config/llm.php b/laravel-app/config/llm.php new file mode 100644 index 0000000..8e4b7a1 --- /dev/null +++ b/laravel-app/config/llm.php @@ -0,0 +1,90 @@ + env('LLM_DEFAULT_MONTHLY_BUDGET', 100.00), + 'default_daily_budget' => env('LLM_DEFAULT_DAILY_BUDGET', 10.00), + + /* + |-------------------------------------------------------------------------- + | Rate Limiting Settings + |-------------------------------------------------------------------------- + | + | Default rate limits for API requests per user + | + */ + 'rate_limit' => [ + 'requests_per_minute' => env('LLM_RATE_LIMIT_PER_MINUTE', 60), + 'requests_per_hour' => env('LLM_RATE_LIMIT_PER_HOUR', 1000), + 'requests_per_day' => env('LLM_RATE_LIMIT_PER_DAY', 10000), + ], + + /* + |-------------------------------------------------------------------------- + | Supported Providers + |-------------------------------------------------------------------------- + | + | List of AI providers supported by the gateway + | + */ + 'providers' => [ + 'openai' => [ + 'name' => 'OpenAI', + 'api_url' => 'https://api.openai.com/v1', + 'models' => ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'], + ], + 'anthropic' => [ + 'name' => 'Anthropic (Claude)', + 'api_url' => 'https://api.anthropic.com/v1', + 'models' => ['claude-opus-4', 'claude-sonnet-4', 'claude-haiku-4'], + ], + 'mistral' => [ + 'name' => 'Mistral AI', + 'api_url' => 'https://api.mistral.ai/v1', + 'models' => ['mistral-large', 'mistral-medium', 'mistral-small'], + ], + 'gemini' => [ + 'name' => 'Google Gemini', + 'api_url' => 'https://generativelanguage.googleapis.com/v1beta', + 'models' => ['gemini-pro', 'gemini-pro-vision'], + ], + 'deepseek' => [ + 'name' => 'DeepSeek', + 'api_url' => 'https://api.deepseek.com/v1', + 'models' => ['deepseek-chat', 'deepseek-coder'], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Logging Settings + |-------------------------------------------------------------------------- + | + | Configuration for request logging + | + */ + 'logging' => [ + 'enabled' => env('LLM_LOGGING_ENABLED', true), + 'queue' => env('LLM_LOGGING_QUEUE', true), + ], + + /* + |-------------------------------------------------------------------------- + | Alert Settings + |-------------------------------------------------------------------------- + | + | Budget alert threshold and notification settings + | + */ + 'alerts' => [ + 'budget_threshold_percentage' => env('LLM_ALERT_THRESHOLD', 80), + 'email_enabled' => env('LLM_ALERT_EMAIL_ENABLED', true), + ], +]; diff --git a/laravel-app/create-admin.php b/laravel-app/create-admin.php new file mode 100644 index 0000000..26a5321 --- /dev/null +++ b/laravel-app/create-admin.php @@ -0,0 +1,31 @@ +make(Illuminate\Contracts\Console\Kernel::class); +$kernel->bootstrap(); + +use App\Models\User; +use Illuminate\Support\Facades\Hash; + +try { + $user = User::firstOrCreate( + ['email' => 'admin@example.com'], + [ + 'name' => 'Admin User', + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ] + ); + + echo "\nโœ… User created successfully!\n"; + echo "๐Ÿ“ง Email: admin@example.com\n"; + echo "๐Ÿ”‘ Password: password\n"; + echo "๐Ÿ‘ค Name: {$user->name}\n"; + echo "๐Ÿ†” ID: {$user->id}\n\n"; + +} catch (Exception $e) { + echo "\nโŒ Error: " . $e->getMessage() . "\n\n"; +} diff --git a/laravel-app/database/migrations/0001_01_01_000000_create_users_table.php b/laravel-app/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..05fb5d9 --- /dev/null +++ b/laravel-app/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/laravel-app/database/migrations/2025_11_16_000001_create_user_provider_credentials_table.php b/laravel-app/database/migrations/2025_11_16_000001_create_user_provider_credentials_table.php new file mode 100644 index 0000000..cdd49e0 --- /dev/null +++ b/laravel-app/database/migrations/2025_11_16_000001_create_user_provider_credentials_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); + $table->string('provider', 50)->comment('openai, anthropic, mistral, gemini, deepseek'); + $table->text('api_key')->comment('Encrypted API key'); + $table->string('organization_id', 255)->nullable()->comment('Optional organization ID'); + $table->boolean('is_active')->default(true); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'provider']); + $table->index('user_id'); + $table->index('provider'); + $table->index('is_active'); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_provider_credentials'); + } +}; diff --git a/laravel-app/database/migrations/2025_11_16_000002_create_llm_requests_table.php b/laravel-app/database/migrations/2025_11_16_000002_create_llm_requests_table.php new file mode 100644 index 0000000..ac64522 --- /dev/null +++ b/laravel-app/database/migrations/2025_11_16_000002_create_llm_requests_table.php @@ -0,0 +1,57 @@ +id(); + $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); + $table->string('provider', 50); + $table->string('model', 100); + + // Request Details + $table->json('request_payload')->comment('Original request'); + $table->json('response_payload')->nullable()->comment('Provider response'); + + // Tokens & Timing + $table->unsignedInteger('prompt_tokens')->default(0); + $table->unsignedInteger('completion_tokens')->default(0); + $table->unsignedInteger('total_tokens')->default(0); + $table->unsignedInteger('response_time_ms')->nullable()->comment('Response time in milliseconds'); + + // Cost Calculation + $table->decimal('prompt_cost', 10, 6)->default(0)->comment('Cost in USD'); + $table->decimal('completion_cost', 10, 6)->default(0)->comment('Cost in USD'); + $table->decimal('total_cost', 10, 6)->default(0)->comment('Cost in USD'); + + // Status & Error Handling + $table->string('status', 20)->default('pending')->comment('pending, success, failed, rate_limited'); + $table->text('error_message')->nullable(); + $table->unsignedInteger('http_status')->nullable(); + + // Metadata + $table->string('ip_address', 45)->nullable(); + $table->string('user_agent', 500)->nullable(); + $table->string('request_id', 100)->nullable()->comment('Unique request identifier'); + + $table->timestamps(); + + $table->index('user_id'); + $table->index(['provider', 'model']); + $table->index('status'); + $table->index('created_at'); + $table->index('request_id'); + $table->index(['user_id', 'created_at', 'total_cost']); + }); + } + + public function down(): void + { + Schema::dropIfExists('llm_requests'); + } +}; diff --git a/laravel-app/database/migrations/2025_11_16_000003_create_model_pricing_table.php b/laravel-app/database/migrations/2025_11_16_000003_create_model_pricing_table.php new file mode 100644 index 0000000..152fd33 --- /dev/null +++ b/laravel-app/database/migrations/2025_11_16_000003_create_model_pricing_table.php @@ -0,0 +1,44 @@ +id(); + $table->string('provider', 50); + $table->string('model', 100); + + // Pricing (per 1M tokens) + $table->decimal('input_price_per_million', 10, 4)->comment('Cost per 1M input tokens in USD'); + $table->decimal('output_price_per_million', 10, 4)->comment('Cost per 1M output tokens in USD'); + + // Model Information + $table->unsignedInteger('context_window')->nullable()->comment('Maximum context size'); + $table->unsignedInteger('max_output_tokens')->nullable()->comment('Maximum output tokens'); + + // Metadata + $table->boolean('is_active')->default(true); + $table->date('effective_from')->default(DB::raw('CURRENT_DATE')); + $table->date('effective_until')->nullable(); + $table->text('notes')->nullable(); + + $table->timestamps(); + + $table->unique(['provider', 'model', 'effective_from']); + $table->index('provider'); + $table->index('model'); + $table->index('is_active'); + $table->index(['effective_from', 'effective_until']); + }); + } + + public function down(): void + { + Schema::dropIfExists('model_pricing'); + } +}; diff --git a/laravel-app/database/migrations/2025_11_16_000004_create_user_budgets_table.php b/laravel-app/database/migrations/2025_11_16_000004_create_user_budgets_table.php new file mode 100644 index 0000000..f30be6c --- /dev/null +++ b/laravel-app/database/migrations/2025_11_16_000004_create_user_budgets_table.php @@ -0,0 +1,48 @@ +id(); + $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); + + // Budget Configuration + $table->decimal('monthly_limit', 10, 2)->default(0)->comment('Monthly spending limit in USD'); + $table->decimal('daily_limit', 10, 2)->nullable()->comment('Optional daily limit'); + + // Current Period Tracking + $table->decimal('current_month_spending', 10, 2)->default(0); + $table->decimal('current_day_spending', 10, 2)->default(0); + + // Period Timestamps + $table->date('month_started_at'); + $table->date('day_started_at'); + + // Alert Thresholds + $table->unsignedInteger('alert_threshold_percentage')->default(80)->comment('Alert at X% of limit'); + $table->timestamp('last_alert_sent_at')->nullable(); + + // Status + $table->boolean('is_budget_exceeded')->default(false); + $table->boolean('is_active')->default(true); + + $table->timestamps(); + + $table->unique('user_id'); + $table->index('is_active'); + $table->index('is_budget_exceeded'); + $table->index('current_month_spending'); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_budgets'); + } +}; diff --git a/laravel-app/database/migrations/2025_11_16_000005_create_rate_limits_table.php b/laravel-app/database/migrations/2025_11_16_000005_create_rate_limits_table.php new file mode 100644 index 0000000..f20c0bd --- /dev/null +++ b/laravel-app/database/migrations/2025_11_16_000005_create_rate_limits_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); + + // Rate Limit Configuration + $table->unsignedInteger('requests_per_minute')->default(60); + $table->unsignedInteger('requests_per_hour')->default(1000); + $table->unsignedInteger('requests_per_day')->default(10000); + + // Current Period Counters + $table->unsignedInteger('current_minute_count')->default(0); + $table->unsignedInteger('current_hour_count')->default(0); + $table->unsignedInteger('current_day_count')->default(0); + + // Period Timestamps + $table->timestamp('minute_started_at')->useCurrent(); + $table->timestamp('hour_started_at')->useCurrent(); + $table->timestamp('day_started_at')->useCurrent(); + + // Status + $table->boolean('is_rate_limited')->default(false); + $table->timestamp('rate_limit_expires_at')->nullable(); + + $table->timestamps(); + + $table->unique('user_id'); + $table->index('is_rate_limited'); + $table->index('rate_limit_expires_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('rate_limits'); + } +}; diff --git a/laravel-app/database/migrations/2025_11_16_000006_seed_additional_model_pricing.php b/laravel-app/database/migrations/2025_11_16_000006_seed_additional_model_pricing.php new file mode 100644 index 0000000..b261750 --- /dev/null +++ b/laravel-app/database/migrations/2025_11_16_000006_seed_additional_model_pricing.php @@ -0,0 +1,159 @@ +insert([ + // Mistral AI Models + [ + 'provider' => 'mistral', + 'model' => 'mistral-large-latest', + 'input_price_per_million' => 2.00, + 'output_price_per_million' => 6.00, + 'context_window' => 128000, + 'max_output_tokens' => 4096, + 'is_active' => true, + 'effective_from' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'mistral', + 'model' => 'mistral-medium-latest', + 'input_price_per_million' => 2.70, + 'output_price_per_million' => 8.10, + 'context_window' => 32000, + 'max_output_tokens' => 4096, + 'is_active' => true, + 'effective_from' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'mistral', + 'model' => 'mistral-small-latest', + 'input_price_per_million' => 0.20, + 'output_price_per_million' => 0.60, + 'context_window' => 32000, + 'max_output_tokens' => 4096, + 'is_active' => true, + 'effective_from' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'mistral', + 'model' => 'open-mistral-7b', + 'input_price_per_million' => 0.25, + 'output_price_per_million' => 0.25, + 'context_window' => 32000, + 'max_output_tokens' => 4096, + 'is_active' => true, + 'effective_from' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'mistral', + 'model' => 'open-mixtral-8x7b', + 'input_price_per_million' => 0.70, + 'output_price_per_million' => 0.70, + 'context_window' => 32000, + 'max_output_tokens' => 4096, + 'is_active' => true, + 'effective_from' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ], + + // Google Gemini Models + [ + 'provider' => 'gemini', + 'model' => 'gemini-pro', + 'input_price_per_million' => 0.50, + 'output_price_per_million' => 1.50, + 'context_window' => 32760, + 'max_output_tokens' => 2048, + 'is_active' => true, + 'effective_from' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'gemini', + 'model' => 'gemini-1.5-pro', + 'input_price_per_million' => 3.50, + 'output_price_per_million' => 10.50, + 'context_window' => 2097152, + 'max_output_tokens' => 8192, + 'is_active' => true, + 'effective_from' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'gemini', + 'model' => 'gemini-1.5-flash', + 'input_price_per_million' => 0.35, + 'output_price_per_million' => 1.05, + 'context_window' => 1048576, + 'max_output_tokens' => 8192, + 'is_active' => true, + 'effective_from' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ], + + // DeepSeek Models + [ + 'provider' => 'deepseek', + 'model' => 'deepseek-chat', + 'input_price_per_million' => 0.14, + 'output_price_per_million' => 0.28, + 'context_window' => 64000, + 'max_output_tokens' => 4096, + 'is_active' => true, + 'effective_from' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'deepseek', + 'model' => 'deepseek-coder', + 'input_price_per_million' => 0.14, + 'output_price_per_million' => 0.28, + 'context_window' => 128000, + 'max_output_tokens' => 4096, + 'is_active' => true, + 'effective_from' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'deepseek', + 'model' => 'deepseek-reasoner', + 'input_price_per_million' => 0.55, + 'output_price_per_million' => 2.19, + 'context_window' => 64000, + 'max_output_tokens' => 8192, + 'is_active' => true, + 'effective_from' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ], + ]); + } + + public function down(): void + { + DB::table('model_pricing') + ->whereIn('provider', ['mistral', 'gemini', 'deepseek']) + ->delete(); + } +}; diff --git a/laravel-app/database/migrations/2025_11_16_200000_create_gateway_users_table.php b/laravel-app/database/migrations/2025_11_16_200000_create_gateway_users_table.php new file mode 100644 index 0000000..583e39c --- /dev/null +++ b/laravel-app/database/migrations/2025_11_16_200000_create_gateway_users_table.php @@ -0,0 +1,29 @@ +string('user_id')->primary(); + $table->string('alias')->nullable(); + $table->string('budget_id')->nullable(); + $table->decimal('spend', 10, 2)->default(0); + $table->boolean('blocked')->default(false); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index('blocked'); + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('gateway_users'); + } +}; diff --git a/laravel-app/database/migrations/2025_11_16_200001_create_budgets_table.php b/laravel-app/database/migrations/2025_11_16_200001_create_budgets_table.php new file mode 100644 index 0000000..69c8e1d --- /dev/null +++ b/laravel-app/database/migrations/2025_11_16_200001_create_budgets_table.php @@ -0,0 +1,27 @@ +string('budget_id')->primary(); + $table->string('name'); + $table->decimal('monthly_limit', 10, 2)->nullable(); + $table->decimal('daily_limit', 10, 2)->nullable(); + $table->string('created_by')->nullable(); + $table->timestamps(); + + $table->index('name'); + }); + } + + public function down(): void + { + Schema::dropIfExists('budgets'); + } +}; diff --git a/laravel-app/database/migrations/2025_11_16_200002_create_api_keys_table.php b/laravel-app/database/migrations/2025_11_16_200002_create_api_keys_table.php new file mode 100644 index 0000000..77d4fdf --- /dev/null +++ b/laravel-app/database/migrations/2025_11_16_200002_create_api_keys_table.php @@ -0,0 +1,36 @@ +string('token')->primary(); + $table->string('user_id'); + $table->string('key_alias')->nullable(); + $table->string('key_name')->nullable(); + $table->json('permissions')->nullable(); + $table->json('models')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamp('expires')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + $table->index('expires'); + + $table->foreign('user_id') + ->references('user_id') + ->on('gateway_users') + ->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('api_keys'); + } +}; diff --git a/laravel-app/database/migrations/2025_11_16_200003_create_usage_logs_table.php b/laravel-app/database/migrations/2025_11_16_200003_create_usage_logs_table.php new file mode 100644 index 0000000..89f49a3 --- /dev/null +++ b/laravel-app/database/migrations/2025_11_16_200003_create_usage_logs_table.php @@ -0,0 +1,45 @@ +string('request_id')->primary(); + $table->string('user_id'); + $table->string('api_key'); + $table->string('model'); + $table->string('provider')->nullable(); + $table->integer('prompt_tokens')->default(0); + $table->integer('completion_tokens')->default(0); + $table->integer('total_tokens')->default(0); + $table->decimal('cost', 10, 6)->default(0); + $table->timestamp('timestamp')->useCurrent(); + $table->json('metadata')->nullable(); + + $table->index('user_id'); + $table->index('api_key'); + $table->index('model'); + $table->index('timestamp'); + + $table->foreign('user_id') + ->references('user_id') + ->on('gateway_users') + ->onDelete('cascade'); + + $table->foreign('api_key') + ->references('token') + ->on('api_keys') + ->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('usage_logs'); + } +}; diff --git a/laravel-app/database/migrations/2025_11_16_210000_add_status_fields_to_usage_logs.php b/laravel-app/database/migrations/2025_11_16_210000_add_status_fields_to_usage_logs.php new file mode 100644 index 0000000..bffc445 --- /dev/null +++ b/laravel-app/database/migrations/2025_11_16_210000_add_status_fields_to_usage_logs.php @@ -0,0 +1,28 @@ +string('status')->default('success')->after('cost'); + $table->string('endpoint')->nullable()->after('provider'); + $table->text('error_message')->nullable()->after('status'); + + // Add index for status for better query performance + $table->index('status'); + }); + } + + public function down(): void + { + Schema::table('usage_logs', function (Blueprint $table) { + $table->dropIndex(['status']); + $table->dropColumn(['status', 'endpoint', 'error_message']); + }); + } +}; diff --git a/laravel-app/database/seeders/AdminUserSeeder.php b/laravel-app/database/seeders/AdminUserSeeder.php new file mode 100644 index 0000000..09519e5 --- /dev/null +++ b/laravel-app/database/seeders/AdminUserSeeder.php @@ -0,0 +1,29 @@ + 'admin@example.com'], + [ + 'name' => 'Admin User', + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ] + ); + + $this->command->info('Admin user created successfully!'); + $this->command->info('Email: admin@example.com'); + $this->command->info('Password: password'); + } +} diff --git a/laravel-app/database/seeders/ModelPricingSeeder.php b/laravel-app/database/seeders/ModelPricingSeeder.php new file mode 100644 index 0000000..1ce71c9 --- /dev/null +++ b/laravel-app/database/seeders/ModelPricingSeeder.php @@ -0,0 +1,158 @@ + 'openai', + 'model' => 'gpt-4o', + 'input_price_per_million' => 2.50, + 'output_price_per_million' => 10.00, + 'context_window' => 128000, + 'max_output_tokens' => 16384, + 'is_active' => true, + 'effective_from' => $now->toDateString(), + 'notes' => 'GPT-4 Omni - Most capable model', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'openai', + 'model' => 'gpt-4o-mini', + 'input_price_per_million' => 0.15, + 'output_price_per_million' => 0.60, + 'context_window' => 128000, + 'max_output_tokens' => 16384, + 'is_active' => true, + 'effective_from' => $now->toDateString(), + 'notes' => 'Cost-efficient model for simple tasks', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'openai', + 'model' => 'gpt-4-turbo', + 'input_price_per_million' => 10.00, + 'output_price_per_million' => 30.00, + 'context_window' => 128000, + 'max_output_tokens' => 4096, + 'is_active' => true, + 'effective_from' => $now->toDateString(), + 'notes' => 'GPT-4 Turbo with vision capabilities', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'openai', + 'model' => 'gpt-3.5-turbo', + 'input_price_per_million' => 0.50, + 'output_price_per_million' => 1.50, + 'context_window' => 16385, + 'max_output_tokens' => 4096, + 'is_active' => true, + 'effective_from' => $now->toDateString(), + 'notes' => 'Fast and affordable legacy model', + 'created_at' => $now, + 'updated_at' => $now, + ], + + // Anthropic Models + [ + 'provider' => 'anthropic', + 'model' => 'claude-opus-4', + 'input_price_per_million' => 15.00, + 'output_price_per_million' => 75.00, + 'context_window' => 200000, + 'max_output_tokens' => 4096, + 'is_active' => true, + 'effective_from' => $now->toDateString(), + 'notes' => 'Most capable Claude model', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'anthropic', + 'model' => 'claude-sonnet-4', + 'input_price_per_million' => 3.00, + 'output_price_per_million' => 15.00, + 'context_window' => 200000, + 'max_output_tokens' => 8192, + 'is_active' => true, + 'effective_from' => $now->toDateString(), + 'notes' => 'Balanced performance and cost', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'anthropic', + 'model' => 'claude-haiku-4', + 'input_price_per_million' => 0.25, + 'output_price_per_million' => 1.25, + 'context_window' => 200000, + 'max_output_tokens' => 4096, + 'is_active' => true, + 'effective_from' => $now->toDateString(), + 'notes' => 'Fast and cost-effective', + 'created_at' => $now, + 'updated_at' => $now, + ], + + // Mistral AI Models + [ + 'provider' => 'mistral', + 'model' => 'mistral-large', + 'input_price_per_million' => 2.00, + 'output_price_per_million' => 6.00, + 'context_window' => 128000, + 'max_output_tokens' => 4096, + 'is_active' => true, + 'effective_from' => $now->toDateString(), + 'notes' => 'Most capable Mistral model', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'mistral', + 'model' => 'mistral-medium', + 'input_price_per_million' => 2.70, + 'output_price_per_million' => 8.10, + 'context_window' => 32000, + 'max_output_tokens' => 4096, + 'is_active' => true, + 'effective_from' => $now->toDateString(), + 'notes' => 'Balanced Mistral model', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'mistral', + 'model' => 'mistral-small', + 'input_price_per_million' => 0.20, + 'output_price_per_million' => 0.60, + 'context_window' => 32000, + 'max_output_tokens' => 4096, + 'is_active' => true, + 'effective_from' => $now->toDateString(), + 'notes' => 'Cost-effective Mistral model', + 'created_at' => $now, + 'updated_at' => $now, + ], + ]; + + DB::table('model_pricing')->insert($pricingData); + + $this->command->info('Model pricing data seeded successfully!'); + $this->command->info('Total models: ' . count($pricingData)); + } +} diff --git a/laravel-app/resources/views/admin/credentials/create.blade.php b/laravel-app/resources/views/admin/credentials/create.blade.php new file mode 100644 index 0000000..a75cf41 --- /dev/null +++ b/laravel-app/resources/views/admin/credentials/create.blade.php @@ -0,0 +1,198 @@ + + +
+

+ {{ __('Add Provider Credentials') }} +

+ + Back to List + +
+
+ +
+
+ @if(session('error')) +
+ {{ session('error') }} +
+ @endif + +
+
+
+ @csrf + + +
+ + + @error('user_id') +

{{ $message }}

+ @enderror +
+ + +
+ + + @error('provider') +

{{ $message }}

+ @enderror + + +
+ + + + + +
+
+ + +
+ + + @error('api_key') +

{{ $message }}

+ @enderror +

+ ๐Ÿ”’ The API key will be encrypted before storage +

+
+ + +
+ + + @error('organization_id') +

{{ $message }}

+ @enderror +

+ Required for some OpenAI enterprise accounts +

+
+ + +
+ + +
+ + +
+ + Cancel + + +
+
+
+
+ + +
+

โ„น๏ธ Important Information

+
    +
  • Each user can only have one set of credentials per provider
  • +
  • API keys are encrypted using Laravel's encryption (AES-256-CBC)
  • +
  • You can test credentials after creation to verify they work
  • +
  • Usage and costs will be tracked per user and provider
  • +
+
+
+
+ + @push('scripts') + + @endpush +
diff --git a/laravel-app/resources/views/admin/credentials/edit.blade.php b/laravel-app/resources/views/admin/credentials/edit.blade.php new file mode 100644 index 0000000..9db3afb --- /dev/null +++ b/laravel-app/resources/views/admin/credentials/edit.blade.php @@ -0,0 +1,208 @@ + + +
+

+ {{ __('Edit Provider Credentials') }} +

+ +
+
+ +
+
+ @if(session('error')) +
+ {{ session('error') }} +
+ @endif + +
+
+
+ @csrf + @method('PUT') + + +
+ +
+
{{ $credential->user->name }}
+
{{ $credential->user->email }}
+
+

+ User cannot be changed after creation +

+
+ + +
+ +
+ + {{ $providers[$credential->provider] ?? ucfirst($credential->provider) }} + +
+

+ Provider cannot be changed after creation +

+
+ + +
+ + + @error('api_key') +

{{ $message }}

+ @enderror +

+ ๐Ÿ”’ Current API key is encrypted and hidden. Enter a new key only if you want to update it. +

+
+ + +
+ + + @error('organization_id') +

{{ $message }}

+ @enderror +

+ Required for some OpenAI enterprise accounts +

+
+ + +
+ is_active) ? 'checked' : '' }} + class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50" + > + +
+ + +
+

Metadata

+
+
+ Created: + {{ $credential->created_at->format('M d, Y H:i') }} +
+
+ Last Updated: + {{ $credential->updated_at->format('M d, Y H:i') }} +
+
+ Last Used: + {{ $credential->last_used_at ? $credential->last_used_at->diffForHumans() : 'Never' }} +
+
+
+ + +
+ + Cancel + + + +
+
+
+
+ + +
+

โš ๏ธ Important Notes

+
    +
  • User and Provider cannot be changed once created
  • +
  • Test the API key before saving to ensure it works
  • +
  • Old API key will be replaced if you enter a new one
  • +
  • Disabling credentials will prevent any API requests using this key
  • +
+
+
+
+ + @push('scripts') + + @endpush +
diff --git a/laravel-app/resources/views/admin/credentials/index.blade.php b/laravel-app/resources/views/admin/credentials/index.blade.php new file mode 100644 index 0000000..e6a5187 --- /dev/null +++ b/laravel-app/resources/views/admin/credentials/index.blade.php @@ -0,0 +1,230 @@ + + +
+

+ {{ __('Provider Credentials') }} +

+ + Add New Credentials + +
+
+ +
+
+ + @if(session('success')) +
+ {{ session('success') }} +
+ @endif + + @if(session('error')) +
+ {{ session('error') }} +
+ @endif + + +
+
+
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ +
+ + + Reset + +
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + @forelse($credentials as $credential) + + + + + + + + + @empty + + + + @endforelse + +
+ User + + Provider + + Organization ID + + Status + + Last Used + + Actions +
+
{{ $credential->user->name }}
+
{{ $credential->user->email }}
+
+ + {{ ucfirst($credential->provider) }} + + + {{ $credential->organization_id ?? '-' }} + + @if($credential->is_active) + + Active + + @else + + Inactive + + @endif + + {{ $credential->last_used_at ? $credential->last_used_at->diffForHumans() : 'Never' }} + + + + View + + + Edit + +
+ @csrf + @method('DELETE') + +
+
+ No credentials found. Add your first credentials +
+
+ + +
+ {{ $credentials->links() }} +
+
+
+
+
+ + @push('scripts') + + @endpush +
diff --git a/laravel-app/resources/views/admin/credentials/show.blade.php b/laravel-app/resources/views/admin/credentials/show.blade.php new file mode 100644 index 0000000..592c097 --- /dev/null +++ b/laravel-app/resources/views/admin/credentials/show.blade.php @@ -0,0 +1,232 @@ + + +
+

+ {{ __('Provider Credentials Details') }} +

+
+ + + Edit + + + Back to List + +
+
+
+ +
+
+ + +
+
+

Basic Information

+ +
+ +
+ +
{{ $credential->user->name }}
+
{{ $credential->user->email }}
+
+ + +
+ + + {{ ucfirst($credential->provider) }} + +
+ + +
+ +
{{ $credential->organization_id ?? 'Not set' }}
+
+ + +
+ + @if($credential->is_active) + + โœ“ Active + + @else + + โœ— Inactive + + @endif +
+ + +
+ +
{{ $credential->created_at->format('M d, Y H:i') }}
+
{{ $credential->created_at->diffForHumans() }}
+
+ + +
+ +
+ @if($credential->last_used_at) + {{ $credential->last_used_at->format('M d, Y H:i') }} +
{{ $credential->last_used_at->diffForHumans() }}
+ @else + Never used + @endif +
+
+
+
+
+ + +
+
+

Usage Statistics

+ +
+ +
+
Total Requests
+
{{ number_format($stats['total_requests']) }}
+
+ + +
+
Total Cost
+
${{ number_format($stats['total_cost'], 2) }}
+
+ + +
+
Total Tokens
+
{{ number_format($stats['total_tokens']) }}
+
+ + +
+
Last 30 Days
+
{{ number_format($stats['last_30_days_requests']) }}
+
+
+
+
+ + +
+
+

Security Information

+ +
+
+ ๐Ÿ”’ +
+
Encryption Status
+
API key is encrypted using AES-256-CBC encryption
+
+
+ +
+ ๐Ÿ”‘ +
+
API Key Format
+
+ {{ $credential->provider }}-********************* + (hidden for security) +
+
+
+ +
+ ๐Ÿ“Š +
+
Usage Tracking
+
All requests using this credential are logged and tracked
+
+
+
+
+
+ + +
+
+

Actions

+ +
+ + + + โœ๏ธ Edit Credentials + + +
+ @csrf + @method('DELETE') + +
+
+
+
+ +
+
+ + @push('scripts') + + @endpush +
diff --git a/laravel-app/resources/views/admin/user-budget/show.blade.php b/laravel-app/resources/views/admin/user-budget/show.blade.php new file mode 100644 index 0000000..338beb8 --- /dev/null +++ b/laravel-app/resources/views/admin/user-budget/show.blade.php @@ -0,0 +1,254 @@ + + +
+

+ Budget & Rate Limits - {{ $user->name }} +

+
+
+ +
+
+ + + @if(session('success')) +
+ {{ session('success') }} +
+ @endif + + +
+
+

User Information

+
+
+ +
{{ $user->name }}
+
+
+ +
{{ $user->email }}
+
+
+ +
{{ $user->created_at->format('M d, Y') }}
+
+
+
+
+ + +
+
+
+

Budget Status

+
+ @csrf + +
+
+ + +
+
+
Monthly Limit
+
${{ number_format($budgetStatus['monthly_limit'], 2) }}
+
+
+
Daily Limit
+
${{ number_format($budgetStatus['daily_limit'], 2) }}
+
+
+
Month Spending
+
${{ number_format($budgetStatus['current_month_spending'], 2) }}
+
{{ round($budgetStatus['monthly_usage_percentage'], 1) }}% used
+
+
+
Today Spending
+
${{ number_format($budgetStatus['current_day_spending'], 2) }}
+
+
+ + +
+
+
+ Monthly Budget Usage + {{ round($budgetStatus['monthly_usage_percentage'], 1) }}% +
+
+
+
+
+
+ + +
+ @csrf + @method('PUT') + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+ + +
+
+
+

Rate Limit Status

+
+ @csrf + +
+
+ + +
+
+
Per Minute
+
+ {{ $rateLimitStatus['current_minute_count'] }} / {{ $rateLimitStatus['requests_per_minute'] }} +
+
{{ $rateLimitStatus['minute_remaining'] }} remaining
+
+
+
Per Hour
+
+ {{ $rateLimitStatus['current_hour_count'] }} / {{ $rateLimitStatus['requests_per_hour'] }} +
+
{{ $rateLimitStatus['hour_remaining'] }} remaining
+
+
+
Per Day
+
+ {{ $rateLimitStatus['current_day_count'] }} / {{ $rateLimitStatus['requests_per_day'] }} +
+
{{ $rateLimitStatus['day_remaining'] }} remaining
+
+
+ + @if($rateLimitStatus['is_rate_limited']) +
+ โš ๏ธ User is currently rate limited until {{ $rateLimitStatus['rate_limit_expires_at']->format('H:i:s') }} +
+ @endif + + +
+ @csrf + @method('PUT') + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+ +
+
+
diff --git a/laravel-app/resources/views/admin/users/index.blade.php b/laravel-app/resources/views/admin/users/index.blade.php new file mode 100644 index 0000000..b1e5fa7 --- /dev/null +++ b/laravel-app/resources/views/admin/users/index.blade.php @@ -0,0 +1,129 @@ + + +

+ {{ __('User Management') }} +

+
+ +
+
+ + +
+
+
+
+
+ +
+ + + Reset + +
+
+
+
+ + +
+
+
+ + + + + + + + + + + + @forelse($users as $user) + + + + + + + + @empty + + + + @endforelse + +
+ User + + Total Requests + + Monthly Budget + + Budget Status + + Actions +
+
{{ $user->name }}
+
{{ $user->email }}
+
+ {{ number_format($user->llm_requests_count) }} + + @if($user->budget) + ${{ number_format($user->budget->monthly_limit, 2) }} + @else + Not set + @endif + + @if($user->budget) + @php + $percentage = $user->budget->monthly_limit > 0 + ? ($user->budget->current_month_spending / $user->budget->monthly_limit) * 100 + : 0; + @endphp + + @if($user->budget->is_budget_exceeded) + + Exceeded + + @elseif($percentage >= 80) + + {{ round($percentage) }}% + + @else + + {{ round($percentage) }}% + + @endif + @else + + No budget + + @endif + + + Manage Budget + +
+ No users found. +
+
+ + +
+ {{ $users->links() }} +
+
+
+
+
+
diff --git a/laravel-app/routes/api.php b/laravel-app/routes/api.php new file mode 100644 index 0000000..4a8465b --- /dev/null +++ b/laravel-app/routes/api.php @@ -0,0 +1,27 @@ +group(function () { + // Chat Completion Endpoint + Route::post('/chat/completions', [ChatCompletionController::class, 'create']) + ->middleware(['checkbudget', 'checkratelimit']); + + // User info endpoint + Route::get('/user', function (Request $request) { + return $request->user(); + }); +}); diff --git a/laravel-app/tests/Unit/Services/AnthropicProviderTest.php b/laravel-app/tests/Unit/Services/AnthropicProviderTest.php new file mode 100644 index 0000000..2b57b1b --- /dev/null +++ b/laravel-app/tests/Unit/Services/AnthropicProviderTest.php @@ -0,0 +1,144 @@ +provider = new AnthropicProvider('test-api-key'); + } + + public function test_builds_request_correctly_with_system_message(): void + { + $messages = [ + ['role' => 'system', 'content' => 'You are a helpful assistant'], + ['role' => 'user', 'content' => 'Hello'] + ]; + + $options = [ + 'model' => 'claude-sonnet-4', + 'temperature' => 0.7, + 'max_tokens' => 2000 + ]; + + $reflection = new \ReflectionClass($this->provider); + $method = $reflection->getMethod('buildRequest'); + $method->setAccessible(true); + + $result = $method->invoke($this->provider, $messages, $options); + + $this->assertEquals('claude-sonnet-4', $result['model']); + $this->assertEquals(0.7, $result['temperature']); + $this->assertEquals(2000, $result['max_tokens']); + $this->assertEquals('You are a helpful assistant', $result['system']); + $this->assertCount(1, $result['messages']); // System message extracted + $this->assertEquals('user', $result['messages'][0]['role']); + } + + public function test_normalizes_response_correctly(): void + { + $rawResponse = [ + 'id' => 'msg_123', + 'model' => 'claude-sonnet-4', + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Hello! How can I assist you today?' + ] + ], + 'role' => 'assistant', + 'stop_reason' => 'end_turn', + 'usage' => [ + 'input_tokens' => 15, + 'output_tokens' => 25 + ] + ]; + + $normalized = $this->provider->normalizeResponse($rawResponse); + + $this->assertEquals('msg_123', $normalized['id']); + $this->assertEquals('claude-sonnet-4', $normalized['model']); + $this->assertEquals('Hello! How can I assist you today?', $normalized['content']); + $this->assertEquals('assistant', $normalized['role']); + $this->assertEquals('end_turn', $normalized['finish_reason']); + $this->assertEquals(15, $normalized['usage']['prompt_tokens']); + $this->assertEquals(25, $normalized['usage']['completion_tokens']); + $this->assertEquals(40, $normalized['usage']['total_tokens']); + } + + public function test_calculates_cost_correctly(): void + { + // Create pricing in database + ModelPricing::create([ + 'provider' => 'anthropic', + 'model' => 'claude-sonnet-4', + 'input_price_per_million' => 3.00, + 'output_price_per_million' => 15.00, + 'is_active' => true, + 'effective_from' => now() + ]); + + Cache::flush(); + + $cost = $this->provider->calculateCost(1000, 500, 'claude-sonnet-4'); + + // Expected: (1000/1M * 3.00) + (500/1M * 15.00) = 0.003 + 0.0075 = 0.0105 + $this->assertEquals(0.0105, $cost); + } + + public function test_handles_api_errors(): void + { + Http::fake([ + 'https://api.anthropic.com/*' => Http::response(['error' => 'Invalid API key'], 401) + ]); + + $this->expectException(\App\Exceptions\ProviderException::class); + $this->expectExceptionMessage('Invalid API key'); + + $this->provider->chatCompletion([ + ['role' => 'user', 'content' => 'test'] + ]); + } + + public function test_get_supported_models(): void + { + $models = $this->provider->getSupportedModels(); + + $this->assertIsArray($models); + $this->assertContains('claude-opus-4', $models); + $this->assertContains('claude-sonnet-4', $models); + $this->assertContains('claude-haiku-4', $models); + } + + public function test_handles_multiple_content_blocks(): void + { + $rawResponse = [ + 'id' => 'msg_456', + 'model' => 'claude-sonnet-4', + 'content' => [ + ['type' => 'text', 'text' => 'First part. '], + ['type' => 'text', 'text' => 'Second part.'] + ], + 'role' => 'assistant', + 'stop_reason' => 'end_turn', + 'usage' => ['input_tokens' => 10, 'output_tokens' => 20] + ]; + + $normalized = $this->provider->normalizeResponse($rawResponse); + + $this->assertEquals('First part. Second part.', $normalized['content']); + } +} diff --git a/laravel-app/tests/Unit/Services/CostCalculatorTest.php b/laravel-app/tests/Unit/Services/CostCalculatorTest.php new file mode 100644 index 0000000..f7d29b7 --- /dev/null +++ b/laravel-app/tests/Unit/Services/CostCalculatorTest.php @@ -0,0 +1,105 @@ +calculator = new CostCalculator(); + } + + public function test_calculates_cost_correctly(): void + { + ModelPricing::create([ + 'provider' => 'openai', + 'model' => 'gpt-4o-mini', + 'input_price_per_million' => 0.15, + 'output_price_per_million' => 0.60, + 'is_active' => true, + 'effective_from' => now(), + ]); + + Cache::flush(); + + $costs = $this->calculator->calculate('openai', 'gpt-4o-mini', 1000, 500); + + // (1000/1M * 0.15) + (500/1M * 0.60) = 0.00015 + 0.0003 = 0.00045 + $this->assertEquals(0.00015, $costs['prompt_cost']); + $this->assertEquals(0.0003, $costs['completion_cost']); + $this->assertEquals(0.00045, $costs['total_cost']); + } + + public function test_returns_zero_cost_for_unknown_model(): void + { + Cache::flush(); + + $costs = $this->calculator->calculate('unknown', 'unknown-model', 1000, 500); + + $this->assertEquals(0.0, $costs['prompt_cost']); + $this->assertEquals(0.0, $costs['completion_cost']); + $this->assertEquals(0.0, $costs['total_cost']); + } + + public function test_uses_cache_for_pricing(): void + { + ModelPricing::create([ + 'provider' => 'anthropic', + 'model' => 'claude-sonnet-4', + 'input_price_per_million' => 3.00, + 'output_price_per_million' => 15.00, + 'is_active' => true, + 'effective_from' => now(), + ]); + + Cache::flush(); + + // First call - should query database + $costs1 = $this->calculator->calculate('anthropic', 'claude-sonnet-4', 1000, 500); + + // Second call - should use cache + $costs2 = $this->calculator->calculate('anthropic', 'claude-sonnet-4', 1000, 500); + + $this->assertEquals($costs1, $costs2); + $this->assertTrue(Cache::has('pricing:anthropic:claude-sonnet-4')); + } + + public function test_estimate_cost(): void + { + ModelPricing::create([ + 'provider' => 'openai', + 'model' => 'gpt-4o', + 'input_price_per_million' => 2.50, + 'output_price_per_million' => 10.00, + 'is_active' => true, + 'effective_from' => now(), + ]); + + Cache::flush(); + + $estimatedCost = $this->calculator->estimateCost('openai', 'gpt-4o', 2000, 1000); + + // (2000/1M * 2.50) + (1000/1M * 10.00) = 0.005 + 0.01 = 0.015 + $this->assertEquals(0.015, $estimatedCost); + } + + public function test_clear_cache(): void + { + Cache::put('pricing:test:model', 'test_data', 3600); + + $this->calculator->clearCache('test', 'model'); + + $this->assertFalse(Cache::has('pricing:test:model')); + } +} diff --git a/laravel-app/tests/Unit/Services/DeepSeekProviderTest.php b/laravel-app/tests/Unit/Services/DeepSeekProviderTest.php new file mode 100644 index 0000000..63ca817 --- /dev/null +++ b/laravel-app/tests/Unit/Services/DeepSeekProviderTest.php @@ -0,0 +1,128 @@ +provider = new DeepSeekProvider('test-api-key'); + } + + public function test_builds_request_correctly(): void + { + $messages = [ + ['role' => 'user', 'content' => 'Write a function'] + ]; + + $options = [ + 'model' => 'deepseek-coder', + 'temperature' => 0.5, + 'max_tokens' => 1500 + ]; + + $reflection = new \ReflectionClass($this->provider); + $method = $reflection->getMethod('buildRequest'); + $method->setAccessible(true); + + $result = $method->invoke($this->provider, $messages, $options); + + $this->assertEquals('deepseek-coder', $result['model']); + $this->assertEquals(0.5, $result['temperature']); + $this->assertEquals(1500, $result['max_tokens']); + $this->assertEquals($messages, $result['messages']); + $this->assertFalse($result['stream']); + } + + public function test_normalizes_response_correctly(): void + { + $rawResponse = [ + 'id' => 'deepseek-123', + 'model' => 'deepseek-coder', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'def hello_world():\n print("Hello, World!")' + ], + 'finish_reason' => 'stop' + ] + ], + 'usage' => [ + 'prompt_tokens' => 8, + 'completion_tokens' => 22, + 'total_tokens' => 30 + ] + ]; + + $normalized = $this->provider->normalizeResponse($rawResponse); + + $this->assertEquals('deepseek-123', $normalized['id']); + $this->assertEquals('deepseek-coder', $normalized['model']); + $this->assertStringContainsString('def hello_world()', $normalized['content']); + $this->assertEquals('assistant', $normalized['role']); + $this->assertEquals('stop', $normalized['finish_reason']); + $this->assertEquals(8, $normalized['usage']['prompt_tokens']); + $this->assertEquals(22, $normalized['usage']['completion_tokens']); + $this->assertEquals(30, $normalized['usage']['total_tokens']); + } + + public function test_calculates_cost_correctly(): void + { + ModelPricing::updateOrCreate( + [ + 'provider' => 'deepseek', + 'model' => 'deepseek-chat', + 'effective_from' => now()->toDateString(), + ], + [ + 'input_price_per_million' => 0.14, + 'output_price_per_million' => 0.28, + 'is_active' => true, + ] + ); + + Cache::flush(); + + $cost = $this->provider->calculateCost(1000, 500, 'deepseek-chat'); + + // Expected: (1000/1M * 0.14) + (500/1M * 0.28) = 0.00014 + 0.00014 = 0.00028 + $this->assertEquals(0.00028, $cost); + } + + public function test_handles_api_errors(): void + { + Http::fake([ + 'https://api.deepseek.com/*' => Http::response(['error' => 'Invalid API key'], 401) + ]); + + $this->expectException(\App\Exceptions\ProviderException::class); + $this->expectExceptionMessage('Invalid API key'); + + $this->provider->chatCompletion([ + ['role' => 'user', 'content' => 'test'] + ]); + } + + public function test_get_supported_models(): void + { + $models = $this->provider->getSupportedModels(); + + $this->assertIsArray($models); + $this->assertContains('deepseek-chat', $models); + $this->assertContains('deepseek-coder', $models); + $this->assertContains('deepseek-reasoner', $models); + } +} diff --git a/laravel-app/tests/Unit/Services/GeminiProviderTest.php b/laravel-app/tests/Unit/Services/GeminiProviderTest.php new file mode 100644 index 0000000..67cdb04 --- /dev/null +++ b/laravel-app/tests/Unit/Services/GeminiProviderTest.php @@ -0,0 +1,147 @@ +provider = new GeminiProvider('test-api-key'); + } + + public function test_builds_request_correctly(): void + { + $messages = [ + ['role' => 'user', 'content' => 'Hello, Gemini!'] + ]; + + $options = [ + 'model' => 'gemini-pro', + 'temperature' => 0.9, + 'max_tokens' => 2000 + ]; + + $reflection = new \ReflectionClass($this->provider); + $method = $reflection->getMethod('buildRequest'); + $method->setAccessible(true); + + $result = $method->invoke($this->provider, $messages, $options); + + $this->assertArrayHasKey('contents', $result); + $this->assertCount(1, $result['contents']); + $this->assertEquals('user', $result['contents'][0]['role']); + $this->assertEquals('Hello, Gemini!', $result['contents'][0]['parts'][0]['text']); + + $this->assertArrayHasKey('generationConfig', $result); + $this->assertEquals(0.9, $result['generationConfig']['temperature']); + $this->assertEquals(2000, $result['generationConfig']['maxOutputTokens']); + } + + public function test_converts_system_messages_to_user(): void + { + $messages = [ + ['role' => 'system', 'content' => 'You are helpful'], + ['role' => 'user', 'content' => 'Hello'] + ]; + + $reflection = new \ReflectionClass($this->provider); + $method = $reflection->getMethod('buildRequest'); + $method->setAccessible(true); + + $result = $method->invoke($this->provider, $messages, []); + + $this->assertEquals('user', $result['contents'][0]['role']); + $this->assertEquals('user', $result['contents'][1]['role']); + } + + public function test_normalizes_response_correctly(): void + { + $rawResponse = [ + 'candidates' => [ + [ + 'content' => [ + 'parts' => [ + ['text' => 'Hello! How can I help you today?'] + ] + ], + 'finishReason' => 'STOP' + ] + ], + 'usageMetadata' => [ + 'promptTokenCount' => 8, + 'candidatesTokenCount' => 15, + 'totalTokenCount' => 23 + ], + 'modelVersion' => 'gemini-pro' + ]; + + $normalized = $this->provider->normalizeResponse($rawResponse); + + $this->assertEquals('gemini-pro', $normalized['model']); + $this->assertEquals('Hello! How can I help you today?', $normalized['content']); + $this->assertEquals('assistant', $normalized['role']); + $this->assertEquals('STOP', $normalized['finish_reason']); + $this->assertEquals(8, $normalized['usage']['prompt_tokens']); + $this->assertEquals(15, $normalized['usage']['completion_tokens']); + $this->assertEquals(23, $normalized['usage']['total_tokens']); + } + + public function test_calculates_cost_correctly(): void + { + ModelPricing::updateOrCreate( + [ + 'provider' => 'gemini', + 'model' => 'gemini-pro', + 'effective_from' => now()->toDateString(), + ], + [ + 'input_price_per_million' => 0.50, + 'output_price_per_million' => 1.50, + 'is_active' => true, + ] + ); + + Cache::flush(); + + $cost = $this->provider->calculateCost(1000, 500, 'gemini-pro'); + + // Expected: (1000/1M * 0.50) + (500/1M * 1.50) = 0.0005 + 0.00075 = 0.00125 + $this->assertEquals(0.00125, $cost); + } + + public function test_handles_api_errors(): void + { + Http::fake([ + 'https://generativelanguage.googleapis.com/*' => Http::response(['error' => 'Invalid API key'], 401) + ]); + + $this->expectException(\App\Exceptions\ProviderException::class); + $this->expectExceptionMessage('Invalid API key'); + + $this->provider->chatCompletion([ + ['role' => 'user', 'content' => 'test'] + ]); + } + + public function test_get_supported_models(): void + { + $models = $this->provider->getSupportedModels(); + + $this->assertIsArray($models); + $this->assertContains('gemini-pro', $models); + $this->assertContains('gemini-1.5-pro', $models); + $this->assertContains('gemini-1.5-flash', $models); + } +} diff --git a/laravel-app/tests/Unit/Services/MistralProviderTest.php b/laravel-app/tests/Unit/Services/MistralProviderTest.php new file mode 100644 index 0000000..2ac33ce --- /dev/null +++ b/laravel-app/tests/Unit/Services/MistralProviderTest.php @@ -0,0 +1,128 @@ +provider = new MistralProvider('test-api-key'); + } + + public function test_builds_request_correctly(): void + { + $messages = [ + ['role' => 'user', 'content' => 'Hello'] + ]; + + $options = [ + 'model' => 'mistral-small-latest', + 'temperature' => 0.8, + 'max_tokens' => 1000 + ]; + + $reflection = new \ReflectionClass($this->provider); + $method = $reflection->getMethod('buildRequest'); + $method->setAccessible(true); + + $result = $method->invoke($this->provider, $messages, $options); + + $this->assertEquals('mistral-small-latest', $result['model']); + $this->assertEquals(0.8, $result['temperature']); + $this->assertEquals(1000, $result['max_tokens']); + $this->assertEquals($messages, $result['messages']); + $this->assertArrayNotHasKey('stream', $result); // stream=false is filtered out + } + + public function test_normalizes_response_correctly(): void + { + $rawResponse = [ + 'id' => 'cmpl-123', + 'model' => 'mistral-small-latest', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Bonjour! Comment puis-je vous aider?' + ], + 'finish_reason' => 'stop' + ] + ], + 'usage' => [ + 'prompt_tokens' => 12, + 'completion_tokens' => 18, + 'total_tokens' => 30 + ] + ]; + + $normalized = $this->provider->normalizeResponse($rawResponse); + + $this->assertEquals('cmpl-123', $normalized['id']); + $this->assertEquals('mistral-small-latest', $normalized['model']); + $this->assertEquals('Bonjour! Comment puis-je vous aider?', $normalized['content']); + $this->assertEquals('assistant', $normalized['role']); + $this->assertEquals('stop', $normalized['finish_reason']); + $this->assertEquals(12, $normalized['usage']['prompt_tokens']); + $this->assertEquals(18, $normalized['usage']['completion_tokens']); + $this->assertEquals(30, $normalized['usage']['total_tokens']); + } + + public function test_calculates_cost_correctly(): void + { + ModelPricing::updateOrCreate( + [ + 'provider' => 'mistral', + 'model' => 'mistral-small-latest', + 'effective_from' => now()->toDateString(), + ], + [ + 'input_price_per_million' => 0.20, + 'output_price_per_million' => 0.60, + 'is_active' => true, + ] + ); + + Cache::flush(); + + $cost = $this->provider->calculateCost(1000, 500, 'mistral-small-latest'); + + // Expected: (1000/1M * 0.20) + (500/1M * 0.60) = 0.0002 + 0.0003 = 0.0005 + $this->assertEquals(0.0005, $cost); + } + + public function test_handles_api_errors(): void + { + Http::fake([ + 'https://api.mistral.ai/*' => Http::response(['error' => 'Invalid API key'], 401) + ]); + + $this->expectException(\App\Exceptions\ProviderException::class); + $this->expectExceptionMessage('Invalid API key'); + + $this->provider->chatCompletion([ + ['role' => 'user', 'content' => 'test'] + ]); + } + + public function test_get_supported_models(): void + { + $models = $this->provider->getSupportedModels(); + + $this->assertIsArray($models); + $this->assertContains('mistral-large-latest', $models); + $this->assertContains('mistral-small-latest', $models); + $this->assertContains('open-mixtral-8x7b', $models); + } +} diff --git a/laravel-app/tests/Unit/Services/OpenAIProviderTest.php b/laravel-app/tests/Unit/Services/OpenAIProviderTest.php new file mode 100644 index 0000000..ff3a36b --- /dev/null +++ b/laravel-app/tests/Unit/Services/OpenAIProviderTest.php @@ -0,0 +1,150 @@ +provider = new OpenAIProvider('test-api-key'); + } + + public function test_builds_request_correctly(): void + { + $messages = [ + ['role' => 'user', 'content' => 'Hello'] + ]; + + $options = [ + 'model' => 'gpt-4o-mini', + 'temperature' => 0.8, + 'max_tokens' => 1000 + ]; + + $reflection = new \ReflectionClass($this->provider); + $method = $reflection->getMethod('buildRequest'); + $method->setAccessible(true); + + $result = $method->invoke($this->provider, $messages, $options); + + $this->assertEquals('gpt-4o-mini', $result['model']); + $this->assertEquals(0.8, $result['temperature']); + $this->assertEquals(1000, $result['max_tokens']); + $this->assertEquals($messages, $result['messages']); + $this->assertFalse($result['stream']); + } + + public function test_normalizes_response_correctly(): void + { + $rawResponse = [ + 'id' => 'chatcmpl-123', + 'model' => 'gpt-4o-mini', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello! How can I help you?' + ], + 'finish_reason' => 'stop' + ] + ], + 'usage' => [ + 'prompt_tokens' => 10, + 'completion_tokens' => 20, + 'total_tokens' => 30 + ] + ]; + + $normalized = $this->provider->normalizeResponse($rawResponse); + + $this->assertEquals('chatcmpl-123', $normalized['id']); + $this->assertEquals('gpt-4o-mini', $normalized['model']); + $this->assertEquals('Hello! How can I help you?', $normalized['content']); + $this->assertEquals('assistant', $normalized['role']); + $this->assertEquals('stop', $normalized['finish_reason']); + $this->assertEquals(10, $normalized['usage']['prompt_tokens']); + $this->assertEquals(20, $normalized['usage']['completion_tokens']); + $this->assertEquals(30, $normalized['usage']['total_tokens']); + } + + public function test_calculates_cost_correctly(): void + { + // Create pricing in database + ModelPricing::create([ + 'provider' => 'openai', + 'model' => 'gpt-4o-mini', + 'input_price_per_million' => 0.15, + 'output_price_per_million' => 0.60, + 'is_active' => true, + 'effective_from' => now() + ]); + + Cache::flush(); + + $cost = $this->provider->calculateCost(1000, 500, 'gpt-4o-mini'); + + // Expected: (1000/1M * 0.15) + (500/1M * 0.60) = 0.00015 + 0.0003 = 0.00045 + $this->assertEquals(0.00045, $cost); + } + + public function test_handles_api_errors(): void + { + Http::fake([ + 'https://api.openai.com/*' => Http::response(['error' => 'Invalid API key'], 401) + ]); + + $this->expectException(\App\Exceptions\ProviderException::class); + $this->expectExceptionMessage('Invalid API key'); + + $this->provider->chatCompletion([ + ['role' => 'user', 'content' => 'test'] + ]); + } + + public function test_retries_on_server_error(): void + { + Http::fake([ + 'https://api.openai.com/*' => Http::sequence() + ->push(['error' => 'Server error'], 500) + ->push(['error' => 'Server error'], 500) + ->push([ + 'id' => 'test-123', + 'model' => 'gpt-4o-mini', + 'choices' => [[ + 'message' => ['content' => 'Success', 'role' => 'assistant'], + 'finish_reason' => 'stop' + ]], + 'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 5, 'total_tokens' => 15] + ], 200) + ]); + + $result = $this->provider->chatCompletion([ + ['role' => 'user', 'content' => 'test'] + ]); + + $this->assertArrayHasKey('id', $result); + $this->assertEquals('test-123', $result['id']); + } + + public function test_get_supported_models(): void + { + $models = $this->provider->getSupportedModels(); + + $this->assertIsArray($models); + $this->assertContains('gpt-4o', $models); + $this->assertContains('gpt-4o-mini', $models); + $this->assertContains('gpt-3.5-turbo', $models); + } +} diff --git a/laravel-app/tests/Unit/Services/ProviderFactoryTest.php b/laravel-app/tests/Unit/Services/ProviderFactoryTest.php new file mode 100644 index 0000000..9e350bc --- /dev/null +++ b/laravel-app/tests/Unit/Services/ProviderFactoryTest.php @@ -0,0 +1,97 @@ +assertInstanceOf(OpenAIProvider::class, $provider); + } + + public function test_creates_anthropic_provider(): void + { + $provider = ProviderFactory::create('anthropic', 'test-key'); + + $this->assertInstanceOf(AnthropicProvider::class, $provider); + } + + public function test_creates_mistral_provider(): void + { + $provider = ProviderFactory::create('mistral', 'test-key'); + + $this->assertInstanceOf(MistralProvider::class, $provider); + } + + public function test_creates_gemini_provider(): void + { + $provider = ProviderFactory::create('gemini', 'test-key'); + + $this->assertInstanceOf(GeminiProvider::class, $provider); + } + + public function test_creates_deepseek_provider(): void + { + $provider = ProviderFactory::create('deepseek', 'test-key'); + + $this->assertInstanceOf(DeepSeekProvider::class, $provider); + } + + public function test_throws_exception_for_unknown_provider(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown provider: unknown'); + + ProviderFactory::create('unknown', 'test-key'); + } + + public function test_is_case_insensitive(): void + { + $provider1 = ProviderFactory::create('OpenAI', 'test-key'); + $provider2 = ProviderFactory::create('ANTHROPIC', 'test-key'); + + $this->assertInstanceOf(OpenAIProvider::class, $provider1); + $this->assertInstanceOf(AnthropicProvider::class, $provider2); + } + + public function test_get_supported_providers(): void + { + $providers = ProviderFactory::getSupportedProviders(); + + $this->assertIsArray($providers); + $this->assertContains('openai', $providers); + $this->assertContains('anthropic', $providers); + $this->assertContains('mistral', $providers); + $this->assertContains('gemini', $providers); + $this->assertContains('deepseek', $providers); + $this->assertCount(5, $providers); + } + + public function test_is_supported(): void + { + $this->assertTrue(ProviderFactory::isSupported('openai')); + $this->assertTrue(ProviderFactory::isSupported('anthropic')); + $this->assertTrue(ProviderFactory::isSupported('mistral')); + $this->assertTrue(ProviderFactory::isSupported('gemini')); + $this->assertTrue(ProviderFactory::isSupported('deepseek')); + $this->assertFalse(ProviderFactory::isSupported('unknown')); + } + + public function test_is_supported_case_insensitive(): void + { + $this->assertTrue(ProviderFactory::isSupported('OpenAI')); + $this->assertTrue(ProviderFactory::isSupported('ANTHROPIC')); + } +}