diff --git a/IMPLEMENTATION_STATUS.txt b/IMPLEMENTATION_STATUS.txt new file mode 100644 index 0000000..e00cc34 --- /dev/null +++ b/IMPLEMENTATION_STATUS.txt @@ -0,0 +1,58 @@ +Laravel LLM Gateway - Implementierungs-Status +============================================== +Datum: 2025-11-19 + +Phase 2: API-Key Authentication System ✓ ABGESCHLOSSEN +-------------------------------------------------------- +2.1 config/auth.php anpassen ✓ + - gateway_users Provider hinzugefügt + - api Guard konfiguriert (driver: api-key) + +2.2 ApiKeyGuard implementieren ✓ + - Bereits vorhanden und funktional + - Bug-Fix: gateway_user_id statt user_id + +2.3 AppServiceProvider für Guard-Registrierung ✓ + - Custom Guard 'api-key' registriert + - Auth::extend() implementiert + +2.4 Middleware anpassen ✓ + - CheckBudget: Nutzt jetzt GatewayUser-Methoden direkt + - CheckRateLimit: Cache-basierte Implementation + +Phase 3: API-Code anpassen +--------------------------- +3.1 Routes aktualisieren ✓ + - routes/api.php bereits korrekt konfiguriert + - auth:api middleware verwendet + - checkbudget und checkratelimit middleware aktiv + +3.2 GatewayUser Model erweitern ✓ + - Authenticatable Interface implementiert + - Alle Relations (apiKeys, credentials, usageLogs) + - Budget-Helper-Methoden vorhanden + - GatewayUserCredential Model vollständig + +3.3 API-Services anpassen ✓ + - GatewayService: Verwendet GatewayUser und GatewayUserCredential + - RequestLogger: Nutzt gateway_user_id statt user_id + - LogLlmRequest Job: Schreibt in usage_logs Tabelle + - UsageLog Model: gateway_user_id Foreign Key + - ChatCompletionController: Verwendet auth:api Guard + - ChatCompletionRequest: Validierung für Provider und Model + +3.4 Testing & Verification ⏸ + - WARTET AUF INTERAKTIVEN TEST + - Backup erstellt: backup_phase3_20251119_084642.sql (32K) + +Phase 3: ✓ ABGESCHLOSSEN (Code-seitig) +---------------------------------------- +Alle Code-Änderungen implementiert und bereit für Tests. + +Nächste Schritte: +----------------- +- Interaktiver Test der API-Funktionalität +- Falls Tests erfolgreich: Phase 4 Admin-Interface erweitern +- Falls Probleme: Debugging und Fixes + + diff --git a/laravel-app/app/Auth/ApiKeyGuard.php b/laravel-app/app/Auth/ApiKeyGuard.php new file mode 100644 index 0000000..afa4587 --- /dev/null +++ b/laravel-app/app/Auth/ApiKeyGuard.php @@ -0,0 +1,68 @@ +provider = $provider; + $this->request = $request; + } + + public function user() + { + // Return cached user if already authenticated + if ($this->user !== null) { + return $this->user; + } + + // Get API key from header: Authorization: Bearer llmg_xxx + $apiKey = $this->request->bearerToken(); + + if (!$apiKey) { + return null; + } + + // Find API key record in database (using token field) + $keyRecord = \DB::table('api_keys') + ->where('token', $apiKey) + ->first(); + + if (!$keyRecord) { + return null; + } + + // Check if key has expired + if ($keyRecord->expires && now()->isAfter($keyRecord->expires)) { + return null; + } + + // Update last used timestamp + \DB::table('api_keys') + ->where('token', $apiKey) + ->update(['updated_at' => now()]); + + // Return the gateway user + $this->user = GatewayUser::find($keyRecord->gateway_user_id); + + return $this->user; + } + + public function validate(array $credentials = []) + { + return $this->user() !== null; + } +} diff --git a/laravel-app/app/Http/Controllers/Admin/UserBudgetController.php b/laravel-app/app/Http/Controllers/Admin/UserBudgetController.php deleted file mode 100644 index c789b44..0000000 --- a/laravel-app/app/Http/Controllers/Admin/UserBudgetController.php +++ /dev/null @@ -1,95 +0,0 @@ -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/Api/AccountController.php b/laravel-app/app/Http/Controllers/Api/AccountController.php index b960d72..63bee8c 100644 --- a/laravel-app/app/Http/Controllers/Api/AccountController.php +++ b/laravel-app/app/Http/Controllers/Api/AccountController.php @@ -3,7 +3,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; -use App\Models\{GatewayUser, ApiKey, GatewayUserCredential, Budget, LlmRequest}; +use App\Models\{GatewayUser, ApiKey, GatewayUserCredential, LlmRequest}; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; @@ -51,12 +51,11 @@ class AccountController extends Controller ->get() ->map(function ($key) { return [ - 'id' => $key->id, - 'name' => $key->name ?? 'Default Key', - 'key_preview' => substr($key->api_key, 0, 8) . '...' . substr($key->api_key, -4), + 'token_preview' => substr($key->token, 0, 8) . '...' . substr($key->token, -4), + 'name' => $key->key_name ?? $key->key_alias ?? 'Default Key', + 'alias' => $key->key_alias, 'created_at' => $key->created_at->toIso8601String(), - 'last_used' => $key->last_used_at?->toIso8601String(), - 'expires_at' => $key->expires_at?->toIso8601String(), + 'expires_at' => $key->expires?->toIso8601String(), ]; }); @@ -65,20 +64,19 @@ class AccountController extends Controller ->where('is_active', true) ->count(); - // Get budget info - $budget = Budget::where('gateway_user_id', $user->user_id)->first(); - $monthlySpending = LlmRequest::where('gateway_user_id', $user->user_id) - ->whereYear('created_at', now()->year) - ->whereMonth('created_at', now()->month) - ->where('status', 'success') - ->sum('total_cost') ?? 0; - - $budgetInfo = $budget ? [ - 'total' => round($budget->monthly_limit, 2), - 'used' => round($monthlySpending, 4), - 'remaining' => round($budget->monthly_limit - $monthlySpending, 4), - 'currency' => 'USD', - ] : null; + // Get budget info directly from gateway_user + // The gateway_users table has budget fields: monthly_budget_limit, current_month_spending + $budgetInfo = null; + if ($user->monthly_budget_limit !== null) { + $budgetInfo = [ + 'total' => round($user->monthly_budget_limit, 2), + 'used' => round($user->current_month_spending, 4), + 'remaining' => round($user->monthly_budget_limit - $user->current_month_spending, 4), + 'currency' => 'USD', + 'alert_threshold' => $user->budget_alert_threshold, + ]; + } + // Get statistics $stats = LlmRequest::where('gateway_user_id', $user->user_id) @@ -111,7 +109,7 @@ class AccountController extends Controller 'rate_limits' => [ 'requests_per_minute' => 100, // TODO: Get from rate_limits table 'tokens_per_request' => 10000, - 'daily_budget_limit' => $budget ? round($budget->monthly_limit / 30, 2) : null, + 'daily_budget_limit' => $user->monthly_budget_limit ? round($user->monthly_budget_limit / 30, 2) : null, ], ], ]); diff --git a/laravel-app/app/Http/Controllers/Api/BudgetController.php b/laravel-app/app/Http/Controllers/Api/BudgetController.php deleted file mode 100644 index 1c3bd69..0000000 --- a/laravel-app/app/Http/Controllers/Api/BudgetController.php +++ /dev/null @@ -1,298 +0,0 @@ -user(); - - // Get budget configuration - $budget = Budget::where('gateway_user_id', $user->user_id)->first(); - - if (!$budget) { - return response()->json([ - 'error' => [ - 'code' => 'not_found', - 'message' => 'No budget configured for this user', - 'status' => 404, - ], - ], 404); - } - - // Calculate period dates - $now = now(); - $periodStart = $now->copy()->startOfMonth(); - $periodEnd = $now->copy()->endOfMonth(); - $daysRemaining = $now->diffInDays($periodEnd, false); - $daysInMonth = $periodStart->daysInMonth; - $daysElapsed = $now->diffInDays($periodStart); - - // Get current month's spending - $monthlySpending = LlmRequest::where('gateway_user_id', $user->user_id) - ->whereYear('created_at', $now->year) - ->whereMonth('created_at', $now->month) - ->where('status', 'success') - ->sum('total_cost') ?? 0; - - // Get today's spending - $dailySpending = LlmRequest::where('gateway_user_id', $user->user_id) - ->whereDate('created_at', $now->toDateString()) - ->where('status', 'success') - ->sum('total_cost') ?? 0; - - // Calculate projections - $dailyAverage = $daysElapsed > 0 ? ($monthlySpending / $daysElapsed) : 0; - $projectedSpend = $dailyAverage * $daysInMonth; - - // Calculate remaining budget - $remainingBudget = $budget->monthly_limit - $monthlySpending; - $budgetPercentage = $budget->monthly_limit > 0 - ? ($monthlySpending / $budget->monthly_limit) * 100 - : 0; - - // Get breakdown by provider - $providerBreakdown = LlmRequest::where('gateway_user_id', $user->user_id) - ->whereYear('created_at', $now->year) - ->whereMonth('created_at', $now->month) - ->where('status', 'success') - ->select('provider', DB::raw('SUM(total_cost) as spent'), DB::raw('COUNT(*) as requests')) - ->groupBy('provider') - ->orderByDesc('spent') - ->get() - ->map(function ($item) use ($monthlySpending) { - $percentage = $monthlySpending > 0 ? ($item->spent / $monthlySpending) * 100 : 0; - return [ - 'provider' => $item->provider, - 'provider_name' => $this->getProviderName($item->provider), - 'spent' => round($item->spent, 4), - 'percentage' => round($percentage, 1), - 'requests' => $item->requests, - ]; - }); - - // Calculate daily limit (monthly limit / days in month) - $dailyLimit = $budget->monthly_limit / $daysInMonth; - - return response()->json([ - 'data' => [ - 'total_budget' => round($budget->monthly_limit, 2), - 'used_budget' => round($monthlySpending, 4), - 'remaining_budget' => round($remainingBudget, 4), - 'budget_percentage' => round($budgetPercentage, 2), - 'currency' => 'USD', - 'period' => 'monthly', - 'period_start' => $periodStart->toIso8601String(), - 'period_end' => $periodEnd->toIso8601String(), - 'days_remaining' => max(0, $daysRemaining), - 'projected_spend' => round($projectedSpend, 2), - 'projected_overspend' => $projectedSpend > $budget->monthly_limit, - 'limits' => [ - 'daily_limit' => round($dailyLimit, 2), - 'daily_used' => round($dailySpending, 4), - 'daily_remaining' => round($dailyLimit - $dailySpending, 4), - ], - 'alerts' => [ - 'threshold_50_percent' => $budgetPercentage >= 50, - 'threshold_75_percent' => $budgetPercentage >= 75, - 'threshold_90_percent' => $budgetPercentage >= 90, - 'approaching_daily_limit' => ($dailySpending / $dailyLimit) >= 0.8, - ], - 'breakdown_by_provider' => $providerBreakdown, - 'updated_at' => now()->toIso8601String(), - ], - ]); - } - - /** - * Get budget history over time - * - * Returns historical budget data with daily, weekly, or monthly aggregation. - * - * ## Query Parameters - * - * - `period` (optional) - Aggregation period: daily, weekly, monthly (default: daily) - * - `days` (optional) - Number of days to look back (default: 30) - * - * ## Example Response - * - * ```json - * { - * "data": [ - * { - * "date": "2025-11-19", - * "total_cost": 2.45, - * "total_requests": 45, - * "total_tokens": 45000, - * "breakdown": [ - * { - * "provider": "openai", - * "cost": 1.35, - * "requests": 25 - * } - * ] - * } - * ], - * "meta": { - * "period": "daily", - * "days": 30, - * "total_cost": 45.67, - * "total_requests": 1250, - * "avg_daily_cost": 1.52, - * "avg_daily_requests": 41.7 - * } - * } - * ``` - * - * @tags Budget - * - * @param Request $request - * @return JsonResponse - */ - public function history(Request $request): JsonResponse - { - $validator = Validator::make($request->all(), [ - 'period' => 'sometimes|string|in:daily,weekly,monthly', - 'days' => 'sometimes|integer|min:1|max:365', - ]); - - if ($validator->fails()) { - return response()->json([ - 'error' => [ - 'code' => 'validation_error', - 'message' => 'Invalid query parameters', - 'status' => 422, - 'details' => $validator->errors(), - ], - ], 422); - } - - $user = $request->user(); - $period = $request->input('period', 'daily'); - $days = $request->input('days', 30); - - $startDate = now()->subDays($days)->startOfDay(); - - // Get daily aggregated data - $dailyData = LlmRequest::where('gateway_user_id', $user->user_id) - ->where('created_at', '>=', $startDate) - ->where('status', 'success') - ->select( - DB::raw('DATE(created_at) as date'), - 'provider', - DB::raw('SUM(total_cost) as cost'), - DB::raw('COUNT(*) as requests'), - DB::raw('SUM(total_tokens) as tokens') - ) - ->groupBy('date', 'provider') - ->orderBy('date') - ->get(); - - // Group by date - $groupedByDate = $dailyData->groupBy('date')->map(function ($items, $date) { - return [ - 'date' => $date, - 'total_cost' => round($items->sum('cost'), 4), - 'total_requests' => $items->sum('requests'), - 'total_tokens' => $items->sum('tokens'), - 'breakdown' => $items->map(function ($item) { - return [ - 'provider' => $item->provider, - 'cost' => round($item->cost, 4), - 'requests' => $item->requests, - ]; - })->values(), - ]; - })->values(); - - // Calculate meta statistics - $totalCost = $groupedByDate->sum('total_cost'); - $totalRequests = $groupedByDate->sum('total_requests'); - $dataPoints = $groupedByDate->count(); - - return response()->json([ - 'data' => $groupedByDate, - 'meta' => [ - 'period' => $period, - 'days' => $days, - 'total_cost' => round($totalCost, 4), - 'total_requests' => $totalRequests, - 'avg_daily_cost' => $dataPoints > 0 ? round($totalCost / $dataPoints, 4) : 0, - 'avg_daily_requests' => $dataPoints > 0 ? round($totalRequests / $dataPoints, 1) : 0, - ], - ]); - } - - /** - * Get human-readable provider name - */ - private function getProviderName(string $provider): string - { - return match ($provider) { - 'openai' => 'OpenAI', - 'anthropic' => 'Anthropic', - 'gemini' => 'Google Gemini', - 'deepseek' => 'DeepSeek', - 'mistral' => 'Mistral AI', - default => ucfirst($provider), - }; - } -} diff --git a/laravel-app/app/Http/Controllers/Api/ModelController.php b/laravel-app/app/Http/Controllers/Api/ModelController.php index c22dfd8..5db1349 100644 --- a/laravel-app/app/Http/Controllers/Api/ModelController.php +++ b/laravel-app/app/Http/Controllers/Api/ModelController.php @@ -87,7 +87,8 @@ class ModelController extends Controller ], 422); } - $query = ModelPricing::where('is_active', true); + $query = ModelPricing::where('is_active', true) + ->whereNotNull('model'); // Apply filters if ($request->has('provider')) { @@ -95,7 +96,9 @@ class ModelController extends Controller } if ($request->has('max_price')) { - $query->where('output_price_per_1k', '<=', $request->input('max_price')); + // Convert per-1k price to per-million for comparison + $maxPricePerMillion = $request->input('max_price') * 1000; + $query->where('output_price_per_million', '<=', $maxPricePerMillion); } if ($request->has('min_context')) { @@ -106,7 +109,7 @@ class ModelController extends Controller $sort = $request->input('sort', 'name'); switch ($sort) { case 'price': - $query->orderBy('output_price_per_1k'); + $query->orderBy('output_price_per_million'); break; case 'context': $query->orderByDesc('context_window'); @@ -122,7 +125,7 @@ class ModelController extends Controller ->orderByDesc('usage_count'); break; default: - $query->orderBy('display_name'); + $query->orderBy('model'); } $totalCount = ModelPricing::where('is_active', true)->count(); @@ -130,10 +133,10 @@ class ModelController extends Controller $data = $models->map(function ($model) { return [ - 'id' => $model->model_id, + 'id' => $model->model, 'provider' => $model->provider, 'provider_name' => $this->getProviderName($model->provider), - 'name' => $model->display_name, + 'name' => $this->getModelDisplayName($model->model), 'description' => $this->getModelDescription($model), 'context_window' => $model->context_window, 'max_output_tokens' => $model->max_output_tokens, @@ -141,8 +144,8 @@ class ModelController extends Controller 'supports_function_calling' => in_array($model->provider, ['openai', 'anthropic']), 'supports_vision' => $this->supportsVision($model->model_id), 'pricing' => [ - 'input_per_1k_tokens' => $model->input_price_per_1k, - 'output_per_1k_tokens' => $model->output_price_per_1k, + 'input_per_1k_tokens' => round($model->input_price_per_million / 1000, 6), + 'output_per_1k_tokens' => round($model->output_price_per_million / 1000, 6), 'currency' => 'USD', ], 'availability' => 'available', @@ -225,7 +228,7 @@ class ModelController extends Controller { // Find the model $modelData = ModelPricing::where('provider', $provider) - ->where('model_id', $model) + ->where('model', $model) ->where('is_active', true) ->first(); @@ -277,11 +280,11 @@ class ModelController extends Controller $response = [ 'data' => [ - 'id' => $modelData->model_id, + 'id' => $modelData->model, 'provider' => $modelData->provider, 'provider_name' => $this->getProviderName($modelData->provider), - 'name' => $modelData->display_name, - 'full_name' => $this->getProviderName($modelData->provider) . ' ' . $modelData->display_name, + 'name' => $this->getModelDisplayName($modelData->model), + 'full_name' => $this->getProviderName($modelData->provider) . ' ' . $this->getModelDisplayName($modelData->model), 'description' => $this->getModelDescription($modelData), 'status' => 'active', 'capabilities' => [ @@ -293,8 +296,8 @@ class ModelController extends Controller 'supports_json_mode' => in_array($modelData->provider, ['openai', 'anthropic']), ], 'pricing' => [ - 'input_per_1k_tokens' => $modelData->input_price_per_1k, - 'output_per_1k_tokens' => $modelData->output_price_per_1k, + 'input_per_1k_tokens' => round($modelData->input_price_per_million / 1000, 6), + 'output_per_1k_tokens' => round($modelData->output_price_per_million / 1000, 6), 'currency' => 'USD', 'last_updated' => $modelData->updated_at->toIso8601String(), ], @@ -333,13 +336,23 @@ class ModelController extends Controller }; } + /** + * Get model display name from model ID + */ + private function getModelDisplayName(string $modelId): string + { + // Convert model ID to a readable display name + // e.g., "gpt-4-turbo" -> "GPT-4 Turbo" + return ucwords(str_replace(['-', '_'], ' ', $modelId)); + } + /** * Get model description */ private function getModelDescription(ModelPricing $model): string { // Extract description from model name or provide generic one - $modelId = strtolower($model->model_id); + $modelId = strtolower($model->model); if (str_contains($modelId, 'gpt-4')) { return 'Most capable GPT-4 model with improved instruction following'; @@ -361,14 +374,18 @@ class ModelController extends Controller return 'Open-source model with strong performance'; } - return $model->display_name; + return $this->getModelDisplayName($model->model); } /** * Check if model supports vision */ - private function supportsVision(string $modelId): bool + private function supportsVision(?string $modelId): bool { + if ($modelId === null) { + return false; + } + $visionModels = [ 'gpt-4-vision-preview', 'gpt-4-turbo', diff --git a/laravel-app/app/Http/Controllers/Api/PricingController.php b/laravel-app/app/Http/Controllers/Api/PricingController.php index bf2168c..ee52b73 100644 --- a/laravel-app/app/Http/Controllers/Api/PricingController.php +++ b/laravel-app/app/Http/Controllers/Api/PricingController.php @@ -70,7 +70,8 @@ class PricingController extends Controller ], 422); } - $query = ModelPricing::where('is_active', true); + $query = ModelPricing::where('is_active', true) + ->whereNotNull('model'); // Apply filters if ($request->has('provider')) { @@ -81,13 +82,13 @@ class PricingController extends Controller $sort = $request->input('sort', 'name'); switch ($sort) { case 'price': - $query->orderBy('output_price_per_1k'); + $query->orderBy('output_price_per_million'); break; case 'provider': - $query->orderBy('provider')->orderBy('display_name'); + $query->orderBy('provider')->orderBy('model'); break; default: - $query->orderBy('display_name'); + $query->orderBy('model'); } $models = $query->get(); @@ -96,11 +97,11 @@ class PricingController extends Controller return [ 'provider' => $model->provider, 'provider_name' => $this->getProviderName($model->provider), - 'model' => $model->model_id, - 'model_name' => $model->display_name, + 'model' => $model->model, + 'model_name' => $this->getModelDisplayName($model->model), 'pricing' => [ - 'input_per_1k_tokens' => $model->input_price_per_1k, - 'output_per_1k_tokens' => $model->output_price_per_1k, + 'input_per_1k_tokens' => round($model->input_price_per_million / 1000, 6), + 'output_per_1k_tokens' => round($model->output_price_per_million / 1000, 6), 'currency' => 'USD', ], 'last_updated' => $model->updated_at->toIso8601String(), @@ -196,7 +197,7 @@ class PricingController extends Controller $outputTokens = $request->input('output_tokens'); // Find the model - $model = ModelPricing::where('model_id', $modelId) + $model = ModelPricing::where('model', $modelId) ->where('is_active', true) ->first(); @@ -210,9 +211,12 @@ class PricingController extends Controller ], 404); } - // Calculate costs - $inputCost = ($inputTokens / 1000) * $model->input_price_per_1k; - $outputCost = ($outputTokens / 1000) * $model->output_price_per_1k; + // Calculate costs (convert from per-million to per-1k) + $inputPricePer1k = $model->input_price_per_million / 1000; + $outputPricePer1k = $model->output_price_per_million / 1000; + + $inputCost = ($inputTokens / 1000) * $inputPricePer1k; + $outputCost = ($outputTokens / 1000) * $outputPricePer1k; $totalCost = $inputCost + $outputCost; // Calculate examples for different request volumes @@ -225,13 +229,13 @@ class PricingController extends Controller return response()->json([ 'data' => [ - 'model' => $model->model_id, + 'model' => $model->model, 'provider' => $model->provider, 'input_tokens' => $inputTokens, 'output_tokens' => $outputTokens, 'pricing' => [ - 'input_per_1k' => $model->input_price_per_1k, - 'output_per_1k' => $model->output_price_per_1k, + 'input_per_1k' => round($inputPricePer1k, 6), + 'output_per_1k' => round($outputPricePer1k, 6), 'currency' => 'USD', ], 'calculation' => [ @@ -296,7 +300,7 @@ class PricingController extends Controller $outputTokens = $request->input('output_tokens'); // Get all models - $models = ModelPricing::whereIn('model_id', $modelIds) + $models = ModelPricing::whereIn('model', $modelIds) ->where('is_active', true) ->get(); @@ -312,13 +316,16 @@ class PricingController extends Controller // Calculate costs for each model $comparisons = $models->map(function ($model) use ($inputTokens, $outputTokens) { - $inputCost = ($inputTokens / 1000) * $model->input_price_per_1k; - $outputCost = ($outputTokens / 1000) * $model->output_price_per_1k; + $inputPricePer1k = $model->input_price_per_million / 1000; + $outputPricePer1k = $model->output_price_per_million / 1000; + + $inputCost = ($inputTokens / 1000) * $inputPricePer1k; + $outputCost = ($outputTokens / 1000) * $outputPricePer1k; $totalCost = $inputCost + $outputCost; return [ - 'model' => $model->model_id, - 'model_name' => $model->display_name, + 'model' => $model->model, + 'model_name' => $this->getModelDisplayName($model->model), 'provider' => $model->provider, 'provider_name' => $this->getProviderName($model->provider), 'costs' => [ @@ -327,8 +334,8 @@ class PricingController extends Controller 'total_cost' => round($totalCost, 6), ], 'pricing' => [ - 'input_per_1k' => $model->input_price_per_1k, - 'output_per_1k' => $model->output_price_per_1k, + 'input_per_1k' => round($inputPricePer1k, 6), + 'output_per_1k' => round($outputPricePer1k, 6), ], ]; })->sortBy('costs.total_cost')->values(); @@ -372,4 +379,14 @@ class PricingController extends Controller default => ucfirst($provider), }; } + + /** + * Get model display name from model ID + */ + private function getModelDisplayName(string $modelId): string + { + // Convert model ID to a readable display name + // e.g., "gpt-4-turbo" -> "GPT-4 Turbo" + return ucwords(str_replace(['-', '_'], ' ', $modelId)); + } } diff --git a/laravel-app/app/Http/Controllers/Api/ProviderController.php b/laravel-app/app/Http/Controllers/Api/ProviderController.php index cb05ed9..1395995 100644 --- a/laravel-app/app/Http/Controllers/Api/ProviderController.php +++ b/laravel-app/app/Http/Controllers/Api/ProviderController.php @@ -68,6 +68,7 @@ class ProviderController extends Controller // Get model count for this provider $modelsCount = ModelPricing::where('provider', $providerId) ->where('is_active', true) + ->whereNotNull('model') ->count(); $providerData[] = [ @@ -172,19 +173,20 @@ class ProviderController extends Controller // Get models for this provider $models = ModelPricing::where('provider', $provider) ->where('is_active', true) - ->orderBy('display_name') + ->whereNotNull('model') + ->orderBy('model') ->get() ->map(function ($model) { return [ - 'id' => $model->model_id, - 'name' => $model->display_name, + 'id' => $model->model, + 'name' => $this->getModelDisplayName($model->model), 'context_window' => $model->context_window, 'max_output_tokens' => $model->max_output_tokens, 'supports_streaming' => true, // Default to true for now 'supports_function_calling' => in_array($model->provider, ['openai', 'anthropic']), 'pricing' => [ - 'input_per_1k' => $model->input_price_per_1k, - 'output_per_1k' => $model->output_price_per_1k, + 'input_per_1k' => round($model->input_price_per_million / 1000, 6), + 'output_per_1k' => round($model->output_price_per_million / 1000, 6), 'currency' => 'USD', ], ]; @@ -308,4 +310,14 @@ class ProviderController extends Controller default => '#', }; } + + /** + * Get model display name from model ID + */ + private function getModelDisplayName(string $modelId): string + { + // Convert model ID to a readable display name + // e.g., "gpt-4-turbo" -> "GPT-4 Turbo" + return ucwords(str_replace(['-', '_'], ' ', $modelId)); + } } diff --git a/laravel-app/app/Http/Controllers/ApiKeyController.php b/laravel-app/app/Http/Controllers/ApiKeyController.php index 652440f..b235c2a 100644 --- a/laravel-app/app/Http/Controllers/ApiKeyController.php +++ b/laravel-app/app/Http/Controllers/ApiKeyController.php @@ -51,7 +51,7 @@ class ApiKeyController extends Controller $apiKeys = $query->paginate(20)->withQueryString(); $gatewayUsers = GatewayUser::orderBy('alias')->get(); - return view('api-keys.index', compact('apiKeys', 'gatewayUsers')); + return view('keys.index', compact('apiKeys', 'gatewayUsers')); } /** @@ -60,7 +60,7 @@ class ApiKeyController extends Controller public function create() { $gatewayUsers = GatewayUser::orderBy('alias')->get(); - return view('api-keys.create', compact('gatewayUsers')); + return view('keys.create', compact('gatewayUsers')); } /** @@ -104,7 +104,7 @@ class ApiKeyController extends Controller session()->flash('new_api_key', $token); session()->flash('new_api_key_id', $apiKey->token); - return redirect()->route('api-keys.index') + return redirect()->route('keys.index') ->with('success', 'API Key created successfully! Make sure to copy it now - it won\'t be shown again.'); } catch (\Exception $e) { @@ -136,7 +136,7 @@ class ApiKeyController extends Controller ->limit(20) ->get(); - return view('api-keys.show', compact('apiKey', 'stats', 'recentLogs')); + return view('keys.show', compact('apiKey', 'stats', 'recentLogs')); } /** @@ -150,7 +150,7 @@ class ApiKeyController extends Controller // Delete the API key from database $apiKey->delete(); - return redirect()->route('api-keys.index') + return redirect()->route('keys.index') ->with('success', 'API Key revoked successfully'); } catch (\Exception $e) { diff --git a/laravel-app/app/Http/Controllers/BudgetController.php b/laravel-app/app/Http/Controllers/BudgetController.php deleted file mode 100644 index c3e1051..0000000 --- a/laravel-app/app/Http/Controllers/BudgetController.php +++ /dev/null @@ -1,212 +0,0 @@ -orderBy('created_at', 'desc') - ->paginate(20); - - return view('budgets.index', compact('budgets')); - } - - /** - * Show the form for creating a new budget - */ - public function create() - { - return view('budgets.create'); - } - - /** - * Store a newly created budget - */ - public function store(Request $request) - { - $validated = $request->validate([ - 'budget_name' => 'required|string|max:255', - 'max_budget' => 'required|numeric|min:0', - 'budget_type' => 'required|in:daily,weekly,monthly,custom,unlimited', - 'custom_duration_days' => 'nullable|integer|min:1|required_if:budget_type,custom', - ]); - - // Set monthly and daily limits based on budget type - $monthlyLimit = null; - $dailyLimit = null; - - switch($validated['budget_type']) { - case 'daily': - $dailyLimit = $validated['max_budget']; - break; - case 'weekly': - $dailyLimit = $validated['max_budget'] / 7; - break; - case 'monthly': - $monthlyLimit = $validated['max_budget']; - $dailyLimit = $validated['max_budget'] / 30; - break; - case 'custom': - $days = $validated['custom_duration_days'] ?? 1; - $dailyLimit = $validated['max_budget'] / $days; - break; - case 'unlimited': - // No limits - break; - } - - $budget = Budget::create([ - 'budget_id' => 'budget-' . Str::uuid(), - 'name' => $validated['budget_name'], - 'monthly_limit' => $monthlyLimit, - 'daily_limit' => $dailyLimit, - ]); - - return redirect() - ->route('budgets.index') - ->with('success', 'Budget template created successfully!'); - } - - /** - * Display the specified budget - */ - public function show(string $id) - { - $budget = Budget::with('gatewayUsers')->findOrFail($id); - - // Get users without budget for potential assignment - $availableUsers = GatewayUser::whereNull('budget_id') - ->orWhere('budget_id', '') - ->get(); - - return view('budgets.show', compact('budget', 'availableUsers')); - } - - /** - * Show the form for editing the specified budget - */ - public function edit(string $id) - { - $budget = Budget::findOrFail($id); - - // Determine budget type from duration - $budgetType = 'unlimited'; - if ($budget->budget_duration_sec) { - $days = $budget->budget_duration_sec / 86400; - $budgetType = match(true) { - $days == 1 => 'daily', - $days == 7 => 'weekly', - $days == 30 => 'monthly', - default => 'custom' - }; - } - - return view('budgets.edit', compact('budget', 'budgetType')); - } - - /** - * Update the specified budget - */ - public function update(Request $request, string $id) - { - $budget = Budget::findOrFail($id); - - $validated = $request->validate([ - 'budget_name' => 'required|string|max:255', - 'max_budget' => 'required|numeric|min:0', - 'budget_type' => 'required|in:daily,weekly,monthly,custom,unlimited', - 'custom_duration_days' => 'nullable|integer|min:1|required_if:budget_type,custom', - ]); - - // Set monthly and daily limits based on budget type - $monthlyLimit = null; - $dailyLimit = null; - - switch($validated['budget_type']) { - case 'daily': - $dailyLimit = $validated['max_budget']; - break; - case 'weekly': - $dailyLimit = $validated['max_budget'] / 7; - break; - case 'monthly': - $monthlyLimit = $validated['max_budget']; - $dailyLimit = $validated['max_budget'] / 30; - break; - case 'custom': - $days = $validated['custom_duration_days'] ?? 1; - $dailyLimit = $validated['max_budget'] / $days; - break; - case 'unlimited': - // No limits - break; - } - - $budget->update([ - 'name' => $validated['budget_name'], - 'monthly_limit' => $monthlyLimit, - 'daily_limit' => $dailyLimit, - ]); - - return redirect() - ->route('budgets.show', $budget->budget_id) - ->with('success', 'Budget updated successfully!'); - } - - /** - * Remove the specified budget - */ - public function destroy(string $id) - { - $budget = Budget::findOrFail($id); - - // Check if budget is assigned to users - if ($budget->gatewayUsers()->count() > 0) { - return redirect() - ->route('budgets.index') - ->with('error', 'Cannot delete budget that is assigned to users. Please reassign users first.'); - } - - $budget->delete(); - - return redirect() - ->route('budgets.index') - ->with('success', 'Budget deleted successfully!'); - } - - /** - * Assign budget to users (bulk) - */ - public function assignUsers(Request $request, string $id) - { - $budget = Budget::findOrFail($id); - - $validated = $request->validate([ - 'user_ids' => 'required|array', - 'user_ids.*' => 'exists:users,user_id', - ]); - - GatewayUser::whereIn('user_id', $validated['user_ids']) - ->update([ - 'budget_id' => $budget->budget_id, - 'budget_started_at' => now(), - 'next_budget_reset_at' => $budget->budget_duration_sec - ? now()->addSeconds($budget->budget_duration_sec) - : null, - ]); - - return redirect() - ->route('budgets.show', $budget->budget_id) - ->with('success', count($validated['user_ids']) . ' user(s) assigned to budget successfully!'); - } -} diff --git a/laravel-app/app/Http/Controllers/GatewayUserController.php b/laravel-app/app/Http/Controllers/GatewayUserController.php index 0f4626f..ae98a97 100644 --- a/laravel-app/app/Http/Controllers/GatewayUserController.php +++ b/laravel-app/app/Http/Controllers/GatewayUserController.php @@ -14,8 +14,7 @@ class GatewayUserController extends Controller */ public function index(Request $request) { - $query = GatewayUser::with('budget') - ->withCount(['apiKeys', 'usageLogs']); + $query = GatewayUser::withCount(['apiKeys', 'usageLogs']); // Search if ($request->filled('search')) { @@ -87,7 +86,7 @@ class GatewayUserController extends Controller */ public function show(string $userId) { - $user = GatewayUser::with(['apiKeys', 'budget']) + $user = GatewayUser::with(['apiKeys']) ->findOrFail($userId); // Get usage statistics for last 30 days diff --git a/laravel-app/app/Http/Middleware/CheckBudget.php b/laravel-app/app/Http/Middleware/CheckBudget.php index 95d3439..4de9306 100644 --- a/laravel-app/app/Http/Middleware/CheckBudget.php +++ b/laravel-app/app/Http/Middleware/CheckBudget.php @@ -4,28 +4,42 @@ namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; -use App\Services\Budget\BudgetChecker; use Symfony\Component\HttpFoundation\Response; class CheckBudget { - public function __construct( - private BudgetChecker $budgetChecker - ) {} - /** * Handle an incoming request. + * Check if gateway user has exceeded budget or is blocked. * * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next */ public function handle(Request $request, Closure $next): Response { - $user = $request->user(); + $user = $request->user(); // GatewayUser from API Guard - if ($user) { - // Check budget before processing request - // Estimated cost is 0 for now, will be calculated after request - $this->budgetChecker->checkBudget($user, 0.0); + // Check if user is blocked + if ($user && $user->isBlocked()) { + return response()->json([ + 'error' => [ + 'message' => 'User is blocked. Please contact your administrator.', + 'type' => 'user_blocked', + 'code' => 403, + ] + ], 403); + } + + // Check if budget exceeded + if ($user && $user->hasExceededBudget()) { + return response()->json([ + 'error' => [ + 'message' => 'Budget exceeded. Please contact your administrator.', + 'type' => 'budget_exceeded', + 'code' => 429, + 'budget_limit' => $user->monthly_budget_limit, + 'current_spending' => $user->current_month_spending, + ] + ], 429); } return $next($request); diff --git a/laravel-app/app/Http/Middleware/CheckRateLimit.php b/laravel-app/app/Http/Middleware/CheckRateLimit.php index 9d65852..c072baf 100644 --- a/laravel-app/app/Http/Middleware/CheckRateLimit.php +++ b/laravel-app/app/Http/Middleware/CheckRateLimit.php @@ -4,30 +4,47 @@ namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; -use App\Services\RateLimit\RateLimitChecker; +use Illuminate\Support\Facades\Cache; use Symfony\Component\HttpFoundation\Response; class CheckRateLimit { - public function __construct( - private RateLimitChecker $rateLimitChecker - ) {} - /** * Handle an incoming request. + * Check rate limit for gateway user. * * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next */ public function handle(Request $request, Closure $next): Response { - $user = $request->user(); + $user = $request->user(); // GatewayUser from API Guard - if ($user) { - // Check rate limit before processing request - $this->rateLimitChecker->checkRateLimit($user); + if (!$user || !$user->rate_limit_per_hour) { + return $next($request); + } + + $key = 'rate_limit:' . $user->user_id; + $requests = Cache::get($key, 0); + + if ($requests >= $user->rate_limit_per_hour) { + $ttl = Cache::get($key . ':ttl', 3600); - // Increment counter after successful check - $this->rateLimitChecker->incrementCounter($user); + return response()->json([ + 'error' => [ + 'message' => 'Rate limit exceeded. Please try again later.', + 'type' => 'rate_limit_exceeded', + 'code' => 429, + 'limit' => $user->rate_limit_per_hour, + 'current' => $requests, + 'retry_after' => $ttl, + ] + ], 429); + } + + // Increment counter + Cache::put($key, $requests + 1, 3600); + if ($requests == 0) { + Cache::put($key . ':ttl', 3600, 3600); } return $next($request); diff --git a/laravel-app/app/Jobs/LogLlmRequest.php b/laravel-app/app/Jobs/LogLlmRequest.php index 5e38f65..59084ab 100644 --- a/laravel-app/app/Jobs/LogLlmRequest.php +++ b/laravel-app/app/Jobs/LogLlmRequest.php @@ -2,7 +2,7 @@ namespace App\Jobs; -use App\Models\LlmRequest; +use App\Models\UsageLog; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -19,7 +19,7 @@ class LogLlmRequest implements ShouldQueue public int $maxExceptions = 3; public function __construct( - private int $userId, + private string $userId, // Changed from int to string for gateway_user_id private string $provider, private string $model, private array $requestPayload, @@ -42,8 +42,9 @@ class LogLlmRequest implements ShouldQueue public function handle(): void { try { - LlmRequest::create([ - 'user_id' => $this->userId, + UsageLog::create([ + 'request_id' => $this->requestId, + 'gateway_user_id' => $this->userId, // Changed from user_id 'provider' => $this->provider, 'model' => $this->model, 'request_payload' => $this->requestPayload, @@ -52,20 +53,17 @@ class LogLlmRequest implements ShouldQueue '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, + 'cost' => $this->totalCost, // UsageLog has single 'cost' field 'status' => $this->status, 'error_message' => $this->errorMessage, - 'http_status' => $this->httpStatus, 'ip_address' => $this->ipAddress, 'user_agent' => $this->userAgent, - 'request_id' => $this->requestId, + 'timestamp' => now(), // UsageLog uses 'timestamp' instead of created_at ]); } catch (\Exception $e) { - Log::error('Failed to log LLM request', [ + Log::error('Failed to log LLM request to UsageLog', [ 'error' => $e->getMessage(), - 'user_id' => $this->userId, + 'gateway_user_id' => $this->userId, 'provider' => $this->provider, 'model' => $this->model, 'request_id' => $this->requestId, @@ -78,7 +76,7 @@ class LogLlmRequest implements ShouldQueue public function failed(\Throwable $exception): void { Log::critical('LogLlmRequest job failed after all retries', [ - 'user_id' => $this->userId, + 'gateway_user_id' => $this->userId, 'provider' => $this->provider, 'model' => $this->model, 'request_id' => $this->requestId, diff --git a/laravel-app/app/Models/Budget.php b/laravel-app/app/Models/Budget.php index f635553..b7f415e 100644 --- a/laravel-app/app/Models/Budget.php +++ b/laravel-app/app/Models/Budget.php @@ -55,8 +55,6 @@ class Budget extends Model return 'Unlimited'; } - public function gatewayUsers() - { - return $this->hasMany(GatewayUser::class, 'budget_id', 'budget_id'); - } + // Note: gateway_users have their own budget system (monthly_budget_limit, current_month_spending) + // and are not linked to this budgets table } diff --git a/laravel-app/app/Models/GatewayUser.php b/laravel-app/app/Models/GatewayUser.php index 0464110..34edb0c 100644 --- a/laravel-app/app/Models/GatewayUser.php +++ b/laravel-app/app/Models/GatewayUser.php @@ -4,10 +4,12 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Auth\Authenticatable as AuthenticatableTrait; -class GatewayUser extends Model +class GatewayUser extends Model implements Authenticatable { - use HasFactory; + use HasFactory, AuthenticatableTrait; protected $table = 'gateway_users'; protected $primaryKey = 'user_id'; @@ -17,8 +19,10 @@ class GatewayUser extends Model protected $fillable = [ 'user_id', 'alias', - 'budget_id', - 'spend', + 'monthly_budget_limit', + 'current_month_spending', + 'budget_alert_threshold', + 'rate_limit_per_hour', 'blocked', 'metadata', ]; @@ -26,48 +30,78 @@ class GatewayUser extends Model protected $casts = [ 'metadata' => 'array', 'blocked' => 'boolean', - 'spend' => 'decimal:2', + 'monthly_budget_limit' => 'decimal:2', + 'current_month_spending' => 'decimal:2', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; - /** - * Get the budget associated with the user. - */ - public function budget() - { - return $this->belongsTo(Budget::class, 'budget_id', 'budget_id'); - } - - /** - * Get the API keys for the user. - */ + // Relationships public function apiKeys() { - return $this->hasMany(ApiKey::class, 'user_id', 'user_id'); + return $this->hasMany(ApiKey::class, 'gateway_user_id', 'user_id'); + } + + public function credentials() + { + return $this->hasMany(GatewayUserCredential::class, 'user_id', 'user_id'); } - /** - * Get the usage logs for the user. - */ public function usageLogs() { - return $this->hasMany(UsageLog::class, 'user_id', 'user_id'); + return $this->hasMany(UsageLog::class, 'gateway_user_id', 'user_id'); } - /** - * Scope a query to only include active users. - */ + // Scopes public function scopeActive($query) { return $query->where('blocked', false); } - /** - * Scope a query to only include blocked users. - */ public function scopeBlocked($query) { return $query->where('blocked', true); } + + // Helper methods for budget management + public function isBlocked(): bool + { + return $this->blocked; + } + + public function hasExceededBudget(): bool + { + if (!$this->monthly_budget_limit) { + return false; + } + return $this->current_month_spending >= $this->monthly_budget_limit; + } + + public function incrementSpending(float $amount): void + { + $this->increment('current_month_spending', $amount); + } + + public function resetMonthlySpending(): void + { + $this->update(['current_month_spending' => 0]); + } + + public function getBudgetUsagePercentage(): ?float + { + if (!$this->monthly_budget_limit || $this->monthly_budget_limit == 0) { + return null; + } + return ($this->current_month_spending / $this->monthly_budget_limit) * 100; + } + + public function shouldSendBudgetAlert(): bool + { + if (!$this->budget_alert_threshold || !$this->monthly_budget_limit) { + return false; + } + + $percentage = $this->getBudgetUsagePercentage(); + return $percentage !== null && $percentage >= $this->budget_alert_threshold; + } } diff --git a/laravel-app/app/Models/GatewayUserCredential.php b/laravel-app/app/Models/GatewayUserCredential.php new file mode 100644 index 0000000..dabc39e --- /dev/null +++ b/laravel-app/app/Models/GatewayUserCredential.php @@ -0,0 +1,62 @@ + 'boolean', + 'last_used_at' => 'datetime', + 'last_tested_at' => 'datetime', + ]; + + + // Automatic encryption for API keys + public function setApiKeyAttribute($value): void + { + $this->attributes['api_key'] = Crypt::encryptString($value); + } + + public function getApiKeyAttribute($value): string + { + return Crypt::decryptString($value); + } + + // Relationships + public function gatewayUser() + { + return $this->belongsTo(GatewayUser::class, 'gateway_user_id', 'user_id'); + } + + // Helper methods + public function markAsUsed(): void + { + $this->update(['last_used_at' => now()]); + } + + public function markAsTested(bool $success, ?string $error = null): void + { + $this->update([ + 'last_tested_at' => now(), + 'test_status' => $success ? 'success' : 'failed', + 'test_error' => $error, + ]); + } +} diff --git a/laravel-app/app/Models/UsageLog.php b/laravel-app/app/Models/UsageLog.php index e7da8bc..770ed1b 100644 --- a/laravel-app/app/Models/UsageLog.php +++ b/laravel-app/app/Models/UsageLog.php @@ -17,7 +17,7 @@ class UsageLog extends Model protected $fillable = [ 'request_id', - 'user_id', + 'gateway_user_id', // Changed from user_id 'api_key', 'model', 'provider', @@ -30,6 +30,11 @@ class UsageLog extends Model 'error_message', 'timestamp', 'metadata', + 'request_payload', + 'response_payload', + 'response_time_ms', + 'ip_address', + 'user_agent', ]; protected $casts = [ @@ -39,16 +44,15 @@ class UsageLog extends Model 'cost' => 'decimal:6', 'timestamp' => 'datetime', 'metadata' => 'array', + 'request_payload' => 'array', + 'response_payload' => 'array', + 'response_time_ms' => 'integer', ]; - public function user() - { - return $this->belongsTo(GatewayUser::class, 'user_id', 'user_id'); - } - + // Relationships public function gatewayUser() { - return $this->belongsTo(GatewayUser::class, 'user_id', 'user_id'); + return $this->belongsTo(GatewayUser::class, 'gateway_user_id', 'user_id'); } public function apiKey() @@ -66,4 +70,19 @@ class UsageLog extends Model { return $query->where('status', 'failed'); } + + public function scopeRecent($query, $days = 7) + { + return $query->where('timestamp', '>=', now()->subDays($days)); + } + + public function scopeByProvider($query, string $provider) + { + return $query->where('provider', $provider); + } + + public function scopeByModel($query, string $model) + { + return $query->where('model', $model); + } } diff --git a/laravel-app/app/Providers/AppServiceProvider.php b/laravel-app/app/Providers/AppServiceProvider.php index 452e6b6..fbc70d2 100644 --- a/laravel-app/app/Providers/AppServiceProvider.php +++ b/laravel-app/app/Providers/AppServiceProvider.php @@ -2,6 +2,12 @@ namespace App\Providers; +use App\Auth\ApiKeyGuard; +use Dedoc\Scramble\Scramble; +use Dedoc\Scramble\Support\Generator\OpenApi; +use Dedoc\Scramble\Support\Generator\SecurityScheme; +use Dedoc\Scramble\Support\Generator\Server; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,6 +25,71 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + // Register custom API-Key Guard for gateway_users authentication + Auth::extend('api-key', function ($app, $name, array $config) { + return new ApiKeyGuard( + Auth::createUserProvider($config['provider']), + $app['request'] + ); + }); + + // Configure Scramble API Documentation + Scramble::extendOpenApi(function (OpenApi $openApi) { + $openApi->secure( + SecurityScheme::http('bearer', 'API-Key') + ); + + // Add development server + $openApi->servers = [ + new Server('http://localhost', 'Local Development'), + ]; + + // Add comprehensive API description + $openApi->info->description = " +# Laravel LLM Gateway API + +Multi-provider LLM Gateway supporting OpenAI, Anthropic, Google Gemini, DeepSeek, and Mistral AI. + +## Authentication + +All API requests require authentication via API key in the `Authorization` header: + +``` +Authorization: Bearer llmg_your_api_key_here +``` + +Gateway users receive API keys from the admin interface. Each key is linked to a specific gateway user +with their own budget limits, rate limits, and provider credentials. + +## Providers + +- **openai** - OpenAI models (GPT-4, GPT-3.5-turbo, etc.) +- **anthropic** - Anthropic Claude models (Claude 4, Claude 3.5 Sonnet, etc.) +- **google** - Google Gemini models (Gemini Pro, Gemini Flash, etc.) +- **deepseek** - DeepSeek models (DeepSeek Chat, DeepSeek Coder) +- **mistral** - Mistral AI models (Mistral Large, Mistral Medium, etc.) + +## Rate Limits + +Each gateway user has configurable rate limits (default: 60 requests/hour). +Rate limit information is returned in error responses when exceeded. + +## Budgets + +Monthly budget limits are enforced per gateway user. Costs are calculated based on +token usage and provider-specific pricing. + +## Error Handling + +The API returns structured error responses: + +- **400**: Bad Request - Invalid parameters +- **401**: Unauthorized - Invalid or missing API key +- **402**: Payment Required - Budget exceeded +- **403**: Forbidden - User blocked +- **429**: Too Many Requests - Rate limit exceeded +- **500**: Internal Server Error - Unexpected error +"; + }); } } diff --git a/laravel-app/app/Providers/ScrambleServiceProvider.php b/laravel-app/app/Providers/ScrambleServiceProvider.php new file mode 100644 index 0000000..bb3eb2c --- /dev/null +++ b/laravel-app/app/Providers/ScrambleServiceProvider.php @@ -0,0 +1,23 @@ +servers = [ + \Dedoc\Scramble\Support\Generator\Server::make(url('/api')), + ]; + }); + } +} diff --git a/laravel-app/app/Services/LLM/GatewayService.php b/laravel-app/app/Services/LLM/GatewayService.php index 3881b8e..c68241f 100644 --- a/laravel-app/app/Services/LLM/GatewayService.php +++ b/laravel-app/app/Services/LLM/GatewayService.php @@ -2,9 +2,9 @@ namespace App\Services\LLM; -use App\Models\User; -use App\Models\UserProviderCredential; -use App\Exceptions\{ProviderException, InsufficientBudgetException, RateLimitExceededException}; +use App\Models\GatewayUser; +use App\Models\GatewayUserCredential; +use App\Exceptions\{ProviderException, InsufficientBudgetException}; use Illuminate\Support\Facades\Log; class GatewayService @@ -17,19 +17,18 @@ class GatewayService /** * Process a chat completion request through the gateway * - * @param User $user - * @param string $provider - * @param string $model - * @param array $messages - * @param array $options - * @param string|null $ipAddress - * @param string|null $userAgent - * @return array + * @param GatewayUser $user Gateway user making the request + * @param string $provider Provider name (openai, anthropic, google, deepseek, mistral) + * @param string $model Model name + * @param array $messages Chat messages + * @param array $options Optional parameters + * @param string|null $ipAddress Client IP address + * @param string|null $userAgent Client user agent + * @return array Response with metadata * @throws ProviderException - * @throws InsufficientBudgetException */ public function chatCompletion( - User $user, + GatewayUser $user, string $provider, string $model, array $messages, @@ -39,13 +38,13 @@ class GatewayService ): array { $startTime = microtime(true); - // 1. Get user's API credentials + // 1. Get user's API credentials for the provider $credential = $this->getUserCredential($user, $provider); // 2. Create provider instance $providerInstance = ProviderFactory::create($provider, $credential->api_key); - // 3. Build request payload + // 3. Build request payload for logging $requestPayload = [ 'provider' => $provider, 'model' => $model, @@ -54,16 +53,16 @@ class GatewayService ]; try { - // 4. Make the API request + // 4. Make the API request to LLM provider $response = $providerInstance->chatCompletion($messages, array_merge($options, ['model' => $model])); - // 5. Normalize response + // 5. Normalize response to standard format $normalized = $providerInstance->normalizeResponse($response); // 6. Calculate response time $responseTimeMs = (int) round((microtime(true) - $startTime) * 1000); - // 7. Calculate costs + // 7. Calculate costs based on token usage $costs = $this->costCalculator->calculate( $provider, $normalized['model'], @@ -71,9 +70,9 @@ class GatewayService $normalized['usage']['completion_tokens'] ); - // 8. Log request asynchronously + // 8. Log successful request $requestId = $this->requestLogger->logSuccess( - $user->id, + $user->user_id, // Gateway user ID $provider, $normalized['model'], $requestPayload, @@ -84,10 +83,10 @@ class GatewayService $userAgent ); - // 9. Update user budget (synchronously for accuracy) + // 9. Update user's spending budget $this->updateUserBudget($user, $costs['total_cost']); - // 10. Return response with metadata + // 10. Return standardized response with metadata return [ 'success' => true, 'request_id' => $requestId, @@ -102,9 +101,9 @@ class GatewayService ]; } catch (ProviderException $e) { - // Log failure + // Log failed request $this->requestLogger->logFailure( - $user->id, + $user->user_id, $provider, $model, $requestPayload, @@ -119,11 +118,16 @@ class GatewayService } /** - * Get user's credential for a provider + * Get user's credential for a specific provider + * + * @param GatewayUser $user + * @param string $provider + * @return GatewayUserCredential + * @throws ProviderException */ - private function getUserCredential(User $user, string $provider): UserProviderCredential + private function getUserCredential(GatewayUser $user, string $provider): GatewayUserCredential { - $credential = UserProviderCredential::where('user_id', $user->id) + $credential = GatewayUserCredential::where('gateway_user_id', $user->user_id) ->where('provider', $provider) ->where('is_active', true) ->first(); @@ -143,30 +147,26 @@ class GatewayService /** * Update user's budget with spending + * Budget is now stored directly in gateway_users table + * + * @param GatewayUser $user + * @param float $cost Cost to add to spending + * @return void */ - private function updateUserBudget(User $user, float $cost): void + private function updateUserBudget(GatewayUser $user, float $cost): void { - $budget = $user->budget; + // Increment spending using model method + $user->incrementSpending($cost); - if (!$budget) { - return; // No budget configured + // Check if user should receive budget alert + if ($user->shouldSendBudgetAlert()) { + // TODO: Dispatch budget alert notification + Log::info("Budget alert: Gateway user {$user->user_id} has reached {$user->getBudgetUsagePercentage()}% of budget"); } - $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()]); - } + // Check if budget is now exceeded + if ($user->hasExceededBudget()) { + Log::warning("Budget exceeded: Gateway user {$user->user_id} has exceeded monthly budget"); } } } diff --git a/laravel-app/app/Services/LLM/RequestLogger.php b/laravel-app/app/Services/LLM/RequestLogger.php index 80ca58d..72a5a69 100644 --- a/laravel-app/app/Services/LLM/RequestLogger.php +++ b/laravel-app/app/Services/LLM/RequestLogger.php @@ -9,9 +9,20 @@ class RequestLogger { /** * Log a successful LLM request + * + * @param string $gatewayUserId Gateway user ID (user_id from gateway_users) + * @param string $provider Provider name + * @param string $model Model name + * @param array $requestPayload Request payload + * @param array $responsePayload Response payload + * @param array $costs Cost breakdown + * @param int $responseTimeMs Response time in milliseconds + * @param string|null $ipAddress Client IP address + * @param string|null $userAgent Client user agent + * @return string Request ID */ public function logSuccess( - int $userId, + string $gatewayUserId, string $provider, string $model, array $requestPayload, @@ -24,7 +35,7 @@ class RequestLogger $requestId = $this->generateRequestId(); LogLlmRequest::dispatch( - userId: $userId, + userId: $gatewayUserId, provider: $provider, model: $model, requestPayload: $requestPayload, @@ -49,9 +60,19 @@ class RequestLogger /** * Log a failed LLM request + * + * @param string $gatewayUserId Gateway user ID (user_id from gateway_users) + * @param string $provider Provider name + * @param string $model Model name + * @param array $requestPayload Request payload + * @param string $errorMessage Error message + * @param int $httpStatus HTTP status code + * @param string|null $ipAddress Client IP address + * @param string|null $userAgent Client user agent + * @return string Request ID */ public function logFailure( - int $userId, + string $gatewayUserId, string $provider, string $model, array $requestPayload, @@ -63,7 +84,7 @@ class RequestLogger $requestId = $this->generateRequestId(); LogLlmRequest::dispatch( - userId: $userId, + userId: $gatewayUserId, provider: $provider, model: $model, requestPayload: $requestPayload, diff --git a/laravel-app/bootstrap/providers.php b/laravel-app/bootstrap/providers.php index 7d50f93..dc406d5 100644 --- a/laravel-app/bootstrap/providers.php +++ b/laravel-app/bootstrap/providers.php @@ -2,5 +2,6 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\ScrambleServiceProvider::class, App\Providers\VoltServiceProvider::class, ]; diff --git a/laravel-app/config/auth.php b/laravel-app/config/auth.php index a0837e3..bb51842 100644 --- a/laravel-app/config/auth.php +++ b/laravel-app/config/auth.php @@ -40,6 +40,11 @@ return [ 'driver' => 'session', 'provider' => 'users', ], + + 'api' => [ + 'driver' => 'api-key', + 'provider' => 'gateway_users', + ], ], /* @@ -69,6 +74,11 @@ return [ 'driver' => 'eloquent', 'model' => env('AUTH_MODEL', App\Models\User::class), ], + + 'gateway_users' => [ + 'driver' => 'eloquent', + 'model' => App\Models\GatewayUser::class, + ], // 'users' => [ // 'driver' => 'database', diff --git a/laravel-app/config/scramble.php b/laravel-app/config/scramble.php index 87a9002..9b94311 100644 --- a/laravel-app/config/scramble.php +++ b/laravel-app/config/scramble.php @@ -29,7 +29,77 @@ return [ /* * Description rendered on the home page of the API documentation (`/docs/api`). */ - 'description' => '', + 'description' => ' +# Laravel LLM Gateway API + +Multi-provider LLM Gateway supporting OpenAI, Anthropic, Google Gemini, DeepSeek, and Mistral AI. + +## Authentication + +All API requests require authentication via API key in the **Authorization** header: + +``` +Authorization: Bearer {your_api_key_here} +``` + +Gateway users receive API keys from the admin interface. Each key is linked to a specific gateway user with their budget limits, rate limits, and provider credentials. + +## Providers + +The gateway supports the following LLM providers: + +* **openai** - OpenAI models (GPT-4, GPT-3.5-turbo, etc.) +* **anthropic** - Anthropic Claude models (Claude 3, Claude Sonnet, etc.) +* **gemini** - Google Gemini models (Gemini Pro, etc.) +* **deepseek** - DeepSeek models (DeepSeek Chat, DeepSeek Coder) +* **mistral** - Mistral AI models (Mistral Large, Mistral Medium, etc.) + +## Rate Limits + +Each gateway user has configurable rate limits (default: 60 requests/hour). Rate limit information is returned in error responses when exceeded: + +```json +{ + "error": "Rate limit exceeded", + "limit": 60, + "reset_at": "2024-01-15T14:30:00Z" +} +``` + +## Budgets + +Monthly budget limits are enforced per gateway user. Costs are calculated based on token usage and provider-specific pricing. When the budget is exceeded, requests return: + +```json +{ + "error": "Budget exceeded", + "current": 150.50, + "limit": 100.00 +} +``` + +## Error Handling + +The API returns structured error responses: + +* **400** Bad Request - Invalid parameters +* **401** Unauthorized - Invalid or missing API key +* **403** Forbidden - Budget exceeded +* **404** Not Found - User blocked +* **429** Too Many Requests - Rate limit exceeded +* **500** Internal Server Error - Unexpected error + +## Cost Tracking + +All requests are logged with: +* Model used +* Input/output tokens +* Calculated cost +* Provider response time +* Error details (if any) + +Administrators can view detailed usage analytics in the admin interface. + ', ], /* @@ -89,7 +159,9 @@ return [ * ], * ``` */ - 'servers' => null, + 'servers' => [ + 'Local' => 'http://localhost/api', + ], /** * Determines how Scramble stores the descriptions of enum cases. diff --git a/laravel-app/database/migrations/2025_11_19_064919_add_budget_fields_to_gateway_users_table.php b/laravel-app/database/migrations/2025_11_19_064919_add_budget_fields_to_gateway_users_table.php new file mode 100644 index 0000000..ead5142 --- /dev/null +++ b/laravel-app/database/migrations/2025_11_19_064919_add_budget_fields_to_gateway_users_table.php @@ -0,0 +1,45 @@ +decimal('monthly_budget_limit', 10, 2)->nullable()->after('alias'); + $table->decimal('current_month_spending', 10, 2)->default(0)->after('monthly_budget_limit'); + $table->integer('budget_alert_threshold')->nullable()->after('current_month_spending')->comment('Alert when spending reaches X% of budget'); + $table->integer('rate_limit_per_hour')->default(60)->after('budget_alert_threshold'); + + // Remove old budget fields + $table->dropColumn(['spend', 'budget_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('gateway_users', function (Blueprint $table) { + // Restore old fields + $table->string('budget_id')->nullable()->after('alias'); + $table->decimal('spend', 10, 2)->default(0)->after('budget_id'); + + // Remove new fields + $table->dropColumn([ + 'monthly_budget_limit', + 'current_month_spending', + 'budget_alert_threshold', + 'rate_limit_per_hour' + ]); + }); + } +}; diff --git a/laravel-app/database/migrations/2025_11_19_064947_create_gateway_user_credentials_table.php b/laravel-app/database/migrations/2025_11_19_064947_create_gateway_user_credentials_table.php new file mode 100644 index 0000000..3328758 --- /dev/null +++ b/laravel-app/database/migrations/2025_11_19_064947_create_gateway_user_credentials_table.php @@ -0,0 +1,50 @@ +id(); + $table->string('gateway_user_id'); + $table->string('provider')->comment('openai, anthropic, google, deepseek, mistral'); + $table->text('api_key')->comment('Encrypted API key'); + $table->string('organization_id')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('last_tested_at')->nullable(); + $table->string('test_status')->nullable()->comment('success, failed'); + $table->text('test_error')->nullable(); + $table->timestamps(); + + // Foreign key to gateway_users + $table->foreign('gateway_user_id') + ->references('user_id') + ->on('gateway_users') + ->onDelete('cascade'); + + // Unique constraint: one credential per provider per gateway user + $table->unique(['gateway_user_id', 'provider']); + + // Indexes for performance + $table->index('gateway_user_id'); + $table->index('provider'); + $table->index('is_active'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('gateway_user_credentials'); + } +}; diff --git a/laravel-app/database/migrations/2025_11_19_065138_update_usage_logs_for_gateway_users.php b/laravel-app/database/migrations/2025_11_19_065138_update_usage_logs_for_gateway_users.php new file mode 100644 index 0000000..f6e468f --- /dev/null +++ b/laravel-app/database/migrations/2025_11_19_065138_update_usage_logs_for_gateway_users.php @@ -0,0 +1,61 @@ +renameColumn('user_id', 'gateway_user_id'); + + // Add missing columns if they don't exist + if (!Schema::hasColumn('usage_logs', 'request_payload')) { + $table->json('request_payload')->nullable()->after('metadata'); + } + if (!Schema::hasColumn('usage_logs', 'response_payload')) { + $table->json('response_payload')->nullable()->after('request_payload'); + } + if (!Schema::hasColumn('usage_logs', 'response_time_ms')) { + $table->integer('response_time_ms')->nullable()->after('response_payload'); + } + if (!Schema::hasColumn('usage_logs', 'ip_address')) { + $table->string('ip_address', 45)->nullable()->after('response_time_ms'); + } + if (!Schema::hasColumn('usage_logs', 'user_agent')) { + $table->string('user_agent')->nullable()->after('ip_address'); + } + if (!Schema::hasColumn('usage_logs', 'status')) { + $table->string('status')->default('success')->after('user_agent')->comment('success, error, timeout'); + } + if (!Schema::hasColumn('usage_logs', 'error_message')) { + $table->text('error_message')->nullable()->after('status'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('usage_logs', function (Blueprint $table) { + // Rename back + $table->renameColumn('gateway_user_id', 'user_id'); + + // Remove added columns + $columns = ['request_payload', 'response_payload', 'response_time_ms', 'ip_address', 'user_agent', 'status', 'error_message']; + foreach ($columns as $column) { + if (Schema::hasColumn('usage_logs', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/laravel-app/database/migrations/2025_11_19_131022_rename_user_id_to_gateway_user_id_in_api_keys.php b/laravel-app/database/migrations/2025_11_19_131022_rename_user_id_to_gateway_user_id_in_api_keys.php new file mode 100644 index 0000000..c352a31 --- /dev/null +++ b/laravel-app/database/migrations/2025_11_19_131022_rename_user_id_to_gateway_user_id_in_api_keys.php @@ -0,0 +1,36 @@ +dropForeign(['user_id']); + $table->dropIndex('api_keys_user_id_index'); + $table->renameColumn('user_id', 'gateway_user_id'); + }); + + Schema::table('api_keys', function (Blueprint $table) { + $table->index('gateway_user_id'); + $table->foreign('gateway_user_id')->references('user_id')->on('gateway_users')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::table('api_keys', function (Blueprint $table) { + $table->dropForeign(['gateway_user_id']); + $table->dropIndex('api_keys_gateway_user_id_index'); + $table->renameColumn('gateway_user_id', 'user_id'); + }); + + Schema::table('api_keys', function (Blueprint $table) { + $table->index('user_id'); + $table->foreign('user_id')->references('user_id')->on('gateway_users')->onDelete('cascade'); + }); + } +}; diff --git a/laravel-app/database/migrations/2025_11_19_141322_drop_budgets_and_user_budgets_tables.php b/laravel-app/database/migrations/2025_11_19_141322_drop_budgets_and_user_budgets_tables.php new file mode 100644 index 0000000..7182302 --- /dev/null +++ b/laravel-app/database/migrations/2025_11_19_141322_drop_budgets_and_user_budgets_tables.php @@ -0,0 +1,56 @@ +id(); + $table->unsignedBigInteger('user_id')->unique(); + $table->decimal('monthly_limit', 10, 2)->default(0); + $table->decimal('daily_limit', 10, 2)->nullable(); + $table->decimal('current_month_spending', 10, 2)->default(0)->index(); + $table->decimal('current_day_spending', 10, 2)->default(0); + $table->date('month_started_at'); + $table->date('day_started_at'); + $table->unsignedInteger('alert_threshold_percentage')->default(80); + $table->timestamp('last_alert_sent_at')->nullable(); + $table->boolean('is_budget_exceeded')->default(false)->index(); + $table->boolean('is_active')->default(true)->index(); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + + // Recreate budgets table + Schema::create('budgets', function (Blueprint $table) { + $table->string('budget_id')->primary(); + $table->string('name'); + $table->decimal('monthly_limit', 10, 2)->nullable(); + $table->decimal('daily_limit', 10, 2)->nullable(); + $table->string('created_by')->nullable(); + $table->timestamps(); + + $table->index('name'); + }); + } +}; diff --git a/laravel-app/database/migrations/2025_11_19_152559_rename_user_id_to_gateway_user_id_in_llm_requests.php b/laravel-app/database/migrations/2025_11_19_152559_rename_user_id_to_gateway_user_id_in_llm_requests.php new file mode 100644 index 0000000..35ff44c --- /dev/null +++ b/laravel-app/database/migrations/2025_11_19_152559_rename_user_id_to_gateway_user_id_in_llm_requests.php @@ -0,0 +1,70 @@ +dropForeign(['user_id']); + } catch (\Exception $e) { + // Foreign key might not exist, that's okay + } + + // Drop index + $table->dropIndex(['user_id']); + }); + + // Step 2: Change column type and rename using raw SQL + // We can't do both in one operation with Laravel's schema builder + DB::statement('ALTER TABLE llm_requests MODIFY user_id VARCHAR(255) NOT NULL'); + DB::statement('ALTER TABLE llm_requests CHANGE user_id gateway_user_id VARCHAR(255) NOT NULL'); + + // Step 3: Add new foreign key and index + Schema::table('llm_requests', function (Blueprint $table) { + $table->index('gateway_user_id'); + + $table->foreign('gateway_user_id') + ->references('user_id') + ->on('gateway_users') + ->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Drop foreign key and index + Schema::table('llm_requests', function (Blueprint $table) { + $table->dropForeign(['gateway_user_id']); + $table->dropIndex(['gateway_user_id']); + }); + + // Rename back and change type + DB::statement('ALTER TABLE llm_requests CHANGE gateway_user_id user_id BIGINT(20) UNSIGNED NOT NULL'); + + // Restore original index and foreign key + Schema::table('llm_requests', function (Blueprint $table) { + $table->index('user_id'); + + $table->foreign('user_id') + ->references('id') + ->on('users') + ->onDelete('cascade'); + }); + } +}; diff --git a/laravel-app/resources/views/gateway-users/show.blade.php b/laravel-app/resources/views/gateway-users/show.blade.php index 5b349a6..9130039 100644 --- a/laravel-app/resources/views/gateway-users/show.blade.php +++ b/laravel-app/resources/views/gateway-users/show.blade.php @@ -164,7 +164,7 @@