Fix API controllers to use correct database column names

- Fix model_pricing table references (model_id -> model, display_name -> model)
- Fix price columns (output_price_per_1k -> output_price_per_million)
- Add price conversion (per_million / 1000 = per_1k) in all API responses
- Add whereNotNull('model') filters to exclude invalid entries
- Add getModelDisplayName() helper method to all controllers
- Fix AccountController to use gateway_users budget fields directly
- Remove Budget model dependencies from AccountController
- Add custom Scramble server URL configuration for API docs
- Create ScrambleServiceProvider to set correct /api prefix
- Add migration to rename user_id to gateway_user_id in llm_requests
- Add custom ApiGuard for gateway_users authentication
- Update all API controllers: AccountController, ModelController, PricingController, ProviderController

All API endpoints now working correctly:
- GET /api/account
- GET /api/models
- GET /api/pricing
- GET /api/providers/{provider}
This commit is contained in:
wtrinkl
2025-11-19 19:36:58 +01:00
parent c65643ac1f
commit cb495e18e3
38 changed files with 1045 additions and 823 deletions

58
IMPLEMENTATION_STATUS.txt Normal file
View File

@@ -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

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Auth;
use App\Models\ApiKey;
use App\Models\GatewayUser;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
class ApiKeyGuard implements Guard
{
use GuardHelpers;
protected $request;
protected $provider;
public function __construct($provider, Request $request)
{
$this->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;
}
}

View File

@@ -1,95 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\UserBudget;
use App\Services\Budget\BudgetChecker;
use App\Services\RateLimit\RateLimitChecker;
use Illuminate\Http\Request;
class UserBudgetController extends Controller
{
public function __construct(
private BudgetChecker $budgetChecker,
private RateLimitChecker $rateLimitChecker
) {}
/**
* Display budget and rate limit status for a user
*/
public function show(User $user)
{
$budgetStatus = $this->budgetChecker->getBudgetStatus($user);
$rateLimitStatus = $this->rateLimitChecker->getRateLimitStatus($user);
return view('admin.user-budget.show', compact('user', 'budgetStatus', 'rateLimitStatus'));
}
/**
* Update budget limits for a user
*/
public function updateBudget(Request $request, User $user)
{
$validated = $request->validate([
'monthly_limit' => 'required|numeric|min:0',
'daily_limit' => 'nullable|numeric|min:0',
'alert_threshold_percentage' => 'required|integer|min:0|max:100',
]);
$budget = $user->budget ?? new UserBudget(['user_id' => $user->id]);
$budget->fill($validated);
$budget->save();
return back()->with('success', 'Budget limits updated successfully!');
}
/**
* Update rate limits for a user
*/
public function updateRateLimit(Request $request, User $user)
{
$validated = $request->validate([
'requests_per_minute' => 'required|integer|min:0',
'requests_per_hour' => 'required|integer|min:0',
'requests_per_day' => 'required|integer|min:0',
]);
$rateLimit = $user->rateLimit ?? new \App\Models\RateLimit(['user_id' => $user->id]);
$rateLimit->fill($validated);
$rateLimit->save();
return back()->with('success', 'Rate limits updated successfully!');
}
/**
* Reset rate limit for a user
*/
public function resetRateLimit(User $user)
{
$this->rateLimitChecker->resetRateLimit($user);
return back()->with('success', 'Rate limit reset successfully!');
}
/**
* Reset budget for a user (admin action)
*/
public function resetBudget(User $user)
{
$budget = $user->budget;
if ($budget) {
$budget->current_month_spending = 0.0;
$budget->current_day_spending = 0.0;
$budget->is_budget_exceeded = false;
$budget->last_alert_sent_at = null;
$budget->month_started_at = now()->startOfMonth();
$budget->day_started_at = now()->startOfDay();
$budget->save();
}
return back()->with('success', 'Budget reset successfully!');
}
}

View File

@@ -3,7 +3,7 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; 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\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@@ -51,12 +51,11 @@ class AccountController extends Controller
->get() ->get()
->map(function ($key) { ->map(function ($key) {
return [ return [
'id' => $key->id, 'token_preview' => substr($key->token, 0, 8) . '...' . substr($key->token, -4),
'name' => $key->name ?? 'Default Key', 'name' => $key->key_name ?? $key->key_alias ?? 'Default Key',
'key_preview' => substr($key->api_key, 0, 8) . '...' . substr($key->api_key, -4), 'alias' => $key->key_alias,
'created_at' => $key->created_at->toIso8601String(), 'created_at' => $key->created_at->toIso8601String(),
'last_used' => $key->last_used_at?->toIso8601String(), 'expires_at' => $key->expires?->toIso8601String(),
'expires_at' => $key->expires_at?->toIso8601String(),
]; ];
}); });
@@ -65,20 +64,19 @@ class AccountController extends Controller
->where('is_active', true) ->where('is_active', true)
->count(); ->count();
// Get budget info // Get budget info directly from gateway_user
$budget = Budget::where('gateway_user_id', $user->user_id)->first(); // The gateway_users table has budget fields: monthly_budget_limit, current_month_spending
$monthlySpending = LlmRequest::where('gateway_user_id', $user->user_id) $budgetInfo = null;
->whereYear('created_at', now()->year) if ($user->monthly_budget_limit !== null) {
->whereMonth('created_at', now()->month) $budgetInfo = [
->where('status', 'success') 'total' => round($user->monthly_budget_limit, 2),
->sum('total_cost') ?? 0; '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,
];
}
$budgetInfo = $budget ? [
'total' => round($budget->monthly_limit, 2),
'used' => round($monthlySpending, 4),
'remaining' => round($budget->monthly_limit - $monthlySpending, 4),
'currency' => 'USD',
] : null;
// Get statistics // Get statistics
$stats = LlmRequest::where('gateway_user_id', $user->user_id) $stats = LlmRequest::where('gateway_user_id', $user->user_id)
@@ -111,7 +109,7 @@ class AccountController extends Controller
'rate_limits' => [ 'rate_limits' => [
'requests_per_minute' => 100, // TODO: Get from rate_limits table 'requests_per_minute' => 100, // TODO: Get from rate_limits table
'tokens_per_request' => 10000, '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,
], ],
], ],
]); ]);

View File

@@ -1,298 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\{Budget, LlmRequest};
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\DB;
class BudgetController extends Controller
{
/**
* Get current budget status
*
* Returns comprehensive budget information including total budget, used budget,
* remaining budget, projections, and breakdowns by provider.
*
* ## Example Response
*
* ```json
* {
* "data": {
* "total_budget": 100.00,
* "used_budget": 45.67,
* "remaining_budget": 54.33,
* "budget_percentage": 45.67,
* "currency": "USD",
* "period": "monthly",
* "period_start": "2025-11-01T00:00:00Z",
* "period_end": "2025-11-30T23:59:59Z",
* "days_remaining": 11,
* "projected_spend": 95.34,
* "projected_overspend": false,
* "limits": {
* "daily_limit": 10.00,
* "daily_used": 2.45,
* "daily_remaining": 7.55
* },
* "alerts": {
* "threshold_50_percent": false,
* "threshold_75_percent": false,
* "threshold_90_percent": false
* },
* "breakdown_by_provider": [
* {
* "provider": "openai",
* "provider_name": "OpenAI",
* "spent": 25.30,
* "percentage": 55.4,
* "requests": 850
* }
* ]
* }
* }
* ```
*
* @tags Budget
*
* @param Request $request
* @return JsonResponse
*/
public function index(Request $request): JsonResponse
{
$user = $request->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),
};
}
}

View File

@@ -87,7 +87,8 @@ class ModelController extends Controller
], 422); ], 422);
} }
$query = ModelPricing::where('is_active', true); $query = ModelPricing::where('is_active', true)
->whereNotNull('model');
// Apply filters // Apply filters
if ($request->has('provider')) { if ($request->has('provider')) {
@@ -95,7 +96,9 @@ class ModelController extends Controller
} }
if ($request->has('max_price')) { 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')) { if ($request->has('min_context')) {
@@ -106,7 +109,7 @@ class ModelController extends Controller
$sort = $request->input('sort', 'name'); $sort = $request->input('sort', 'name');
switch ($sort) { switch ($sort) {
case 'price': case 'price':
$query->orderBy('output_price_per_1k'); $query->orderBy('output_price_per_million');
break; break;
case 'context': case 'context':
$query->orderByDesc('context_window'); $query->orderByDesc('context_window');
@@ -122,7 +125,7 @@ class ModelController extends Controller
->orderByDesc('usage_count'); ->orderByDesc('usage_count');
break; break;
default: default:
$query->orderBy('display_name'); $query->orderBy('model');
} }
$totalCount = ModelPricing::where('is_active', true)->count(); $totalCount = ModelPricing::where('is_active', true)->count();
@@ -130,10 +133,10 @@ class ModelController extends Controller
$data = $models->map(function ($model) { $data = $models->map(function ($model) {
return [ return [
'id' => $model->model_id, 'id' => $model->model,
'provider' => $model->provider, 'provider' => $model->provider,
'provider_name' => $this->getProviderName($model->provider), 'provider_name' => $this->getProviderName($model->provider),
'name' => $model->display_name, 'name' => $this->getModelDisplayName($model->model),
'description' => $this->getModelDescription($model), 'description' => $this->getModelDescription($model),
'context_window' => $model->context_window, 'context_window' => $model->context_window,
'max_output_tokens' => $model->max_output_tokens, '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_function_calling' => in_array($model->provider, ['openai', 'anthropic']),
'supports_vision' => $this->supportsVision($model->model_id), 'supports_vision' => $this->supportsVision($model->model_id),
'pricing' => [ 'pricing' => [
'input_per_1k_tokens' => $model->input_price_per_1k, 'input_per_1k_tokens' => round($model->input_price_per_million / 1000, 6),
'output_per_1k_tokens' => $model->output_price_per_1k, 'output_per_1k_tokens' => round($model->output_price_per_million / 1000, 6),
'currency' => 'USD', 'currency' => 'USD',
], ],
'availability' => 'available', 'availability' => 'available',
@@ -225,7 +228,7 @@ class ModelController extends Controller
{ {
// Find the model // Find the model
$modelData = ModelPricing::where('provider', $provider) $modelData = ModelPricing::where('provider', $provider)
->where('model_id', $model) ->where('model', $model)
->where('is_active', true) ->where('is_active', true)
->first(); ->first();
@@ -277,11 +280,11 @@ class ModelController extends Controller
$response = [ $response = [
'data' => [ 'data' => [
'id' => $modelData->model_id, 'id' => $modelData->model,
'provider' => $modelData->provider, 'provider' => $modelData->provider,
'provider_name' => $this->getProviderName($modelData->provider), 'provider_name' => $this->getProviderName($modelData->provider),
'name' => $modelData->display_name, 'name' => $this->getModelDisplayName($modelData->model),
'full_name' => $this->getProviderName($modelData->provider) . ' ' . $modelData->display_name, 'full_name' => $this->getProviderName($modelData->provider) . ' ' . $this->getModelDisplayName($modelData->model),
'description' => $this->getModelDescription($modelData), 'description' => $this->getModelDescription($modelData),
'status' => 'active', 'status' => 'active',
'capabilities' => [ 'capabilities' => [
@@ -293,8 +296,8 @@ class ModelController extends Controller
'supports_json_mode' => in_array($modelData->provider, ['openai', 'anthropic']), 'supports_json_mode' => in_array($modelData->provider, ['openai', 'anthropic']),
], ],
'pricing' => [ 'pricing' => [
'input_per_1k_tokens' => $modelData->input_price_per_1k, 'input_per_1k_tokens' => round($modelData->input_price_per_million / 1000, 6),
'output_per_1k_tokens' => $modelData->output_price_per_1k, 'output_per_1k_tokens' => round($modelData->output_price_per_million / 1000, 6),
'currency' => 'USD', 'currency' => 'USD',
'last_updated' => $modelData->updated_at->toIso8601String(), '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 * Get model description
*/ */
private function getModelDescription(ModelPricing $model): string private function getModelDescription(ModelPricing $model): string
{ {
// Extract description from model name or provide generic one // Extract description from model name or provide generic one
$modelId = strtolower($model->model_id); $modelId = strtolower($model->model);
if (str_contains($modelId, 'gpt-4')) { if (str_contains($modelId, 'gpt-4')) {
return 'Most capable GPT-4 model with improved instruction following'; 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 'Open-source model with strong performance';
} }
return $model->display_name; return $this->getModelDisplayName($model->model);
} }
/** /**
* Check if model supports vision * Check if model supports vision
*/ */
private function supportsVision(string $modelId): bool private function supportsVision(?string $modelId): bool
{ {
if ($modelId === null) {
return false;
}
$visionModels = [ $visionModels = [
'gpt-4-vision-preview', 'gpt-4-vision-preview',
'gpt-4-turbo', 'gpt-4-turbo',

View File

@@ -70,7 +70,8 @@ class PricingController extends Controller
], 422); ], 422);
} }
$query = ModelPricing::where('is_active', true); $query = ModelPricing::where('is_active', true)
->whereNotNull('model');
// Apply filters // Apply filters
if ($request->has('provider')) { if ($request->has('provider')) {
@@ -81,13 +82,13 @@ class PricingController extends Controller
$sort = $request->input('sort', 'name'); $sort = $request->input('sort', 'name');
switch ($sort) { switch ($sort) {
case 'price': case 'price':
$query->orderBy('output_price_per_1k'); $query->orderBy('output_price_per_million');
break; break;
case 'provider': case 'provider':
$query->orderBy('provider')->orderBy('display_name'); $query->orderBy('provider')->orderBy('model');
break; break;
default: default:
$query->orderBy('display_name'); $query->orderBy('model');
} }
$models = $query->get(); $models = $query->get();
@@ -96,11 +97,11 @@ class PricingController extends Controller
return [ return [
'provider' => $model->provider, 'provider' => $model->provider,
'provider_name' => $this->getProviderName($model->provider), 'provider_name' => $this->getProviderName($model->provider),
'model' => $model->model_id, 'model' => $model->model,
'model_name' => $model->display_name, 'model_name' => $this->getModelDisplayName($model->model),
'pricing' => [ 'pricing' => [
'input_per_1k_tokens' => $model->input_price_per_1k, 'input_per_1k_tokens' => round($model->input_price_per_million / 1000, 6),
'output_per_1k_tokens' => $model->output_price_per_1k, 'output_per_1k_tokens' => round($model->output_price_per_million / 1000, 6),
'currency' => 'USD', 'currency' => 'USD',
], ],
'last_updated' => $model->updated_at->toIso8601String(), 'last_updated' => $model->updated_at->toIso8601String(),
@@ -196,7 +197,7 @@ class PricingController extends Controller
$outputTokens = $request->input('output_tokens'); $outputTokens = $request->input('output_tokens');
// Find the model // Find the model
$model = ModelPricing::where('model_id', $modelId) $model = ModelPricing::where('model', $modelId)
->where('is_active', true) ->where('is_active', true)
->first(); ->first();
@@ -210,9 +211,12 @@ class PricingController extends Controller
], 404); ], 404);
} }
// Calculate costs // Calculate costs (convert from per-million to per-1k)
$inputCost = ($inputTokens / 1000) * $model->input_price_per_1k; $inputPricePer1k = $model->input_price_per_million / 1000;
$outputCost = ($outputTokens / 1000) * $model->output_price_per_1k; $outputPricePer1k = $model->output_price_per_million / 1000;
$inputCost = ($inputTokens / 1000) * $inputPricePer1k;
$outputCost = ($outputTokens / 1000) * $outputPricePer1k;
$totalCost = $inputCost + $outputCost; $totalCost = $inputCost + $outputCost;
// Calculate examples for different request volumes // Calculate examples for different request volumes
@@ -225,13 +229,13 @@ class PricingController extends Controller
return response()->json([ return response()->json([
'data' => [ 'data' => [
'model' => $model->model_id, 'model' => $model->model,
'provider' => $model->provider, 'provider' => $model->provider,
'input_tokens' => $inputTokens, 'input_tokens' => $inputTokens,
'output_tokens' => $outputTokens, 'output_tokens' => $outputTokens,
'pricing' => [ 'pricing' => [
'input_per_1k' => $model->input_price_per_1k, 'input_per_1k' => round($inputPricePer1k, 6),
'output_per_1k' => $model->output_price_per_1k, 'output_per_1k' => round($outputPricePer1k, 6),
'currency' => 'USD', 'currency' => 'USD',
], ],
'calculation' => [ 'calculation' => [
@@ -296,7 +300,7 @@ class PricingController extends Controller
$outputTokens = $request->input('output_tokens'); $outputTokens = $request->input('output_tokens');
// Get all models // Get all models
$models = ModelPricing::whereIn('model_id', $modelIds) $models = ModelPricing::whereIn('model', $modelIds)
->where('is_active', true) ->where('is_active', true)
->get(); ->get();
@@ -312,13 +316,16 @@ class PricingController extends Controller
// Calculate costs for each model // Calculate costs for each model
$comparisons = $models->map(function ($model) use ($inputTokens, $outputTokens) { $comparisons = $models->map(function ($model) use ($inputTokens, $outputTokens) {
$inputCost = ($inputTokens / 1000) * $model->input_price_per_1k; $inputPricePer1k = $model->input_price_per_million / 1000;
$outputCost = ($outputTokens / 1000) * $model->output_price_per_1k; $outputPricePer1k = $model->output_price_per_million / 1000;
$inputCost = ($inputTokens / 1000) * $inputPricePer1k;
$outputCost = ($outputTokens / 1000) * $outputPricePer1k;
$totalCost = $inputCost + $outputCost; $totalCost = $inputCost + $outputCost;
return [ return [
'model' => $model->model_id, 'model' => $model->model,
'model_name' => $model->display_name, 'model_name' => $this->getModelDisplayName($model->model),
'provider' => $model->provider, 'provider' => $model->provider,
'provider_name' => $this->getProviderName($model->provider), 'provider_name' => $this->getProviderName($model->provider),
'costs' => [ 'costs' => [
@@ -327,8 +334,8 @@ class PricingController extends Controller
'total_cost' => round($totalCost, 6), 'total_cost' => round($totalCost, 6),
], ],
'pricing' => [ 'pricing' => [
'input_per_1k' => $model->input_price_per_1k, 'input_per_1k' => round($inputPricePer1k, 6),
'output_per_1k' => $model->output_price_per_1k, 'output_per_1k' => round($outputPricePer1k, 6),
], ],
]; ];
})->sortBy('costs.total_cost')->values(); })->sortBy('costs.total_cost')->values();
@@ -372,4 +379,14 @@ class PricingController extends Controller
default => ucfirst($provider), 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));
}
} }

View File

@@ -68,6 +68,7 @@ class ProviderController extends Controller
// Get model count for this provider // Get model count for this provider
$modelsCount = ModelPricing::where('provider', $providerId) $modelsCount = ModelPricing::where('provider', $providerId)
->where('is_active', true) ->where('is_active', true)
->whereNotNull('model')
->count(); ->count();
$providerData[] = [ $providerData[] = [
@@ -172,19 +173,20 @@ class ProviderController extends Controller
// Get models for this provider // Get models for this provider
$models = ModelPricing::where('provider', $provider) $models = ModelPricing::where('provider', $provider)
->where('is_active', true) ->where('is_active', true)
->orderBy('display_name') ->whereNotNull('model')
->orderBy('model')
->get() ->get()
->map(function ($model) { ->map(function ($model) {
return [ return [
'id' => $model->model_id, 'id' => $model->model,
'name' => $model->display_name, 'name' => $this->getModelDisplayName($model->model),
'context_window' => $model->context_window, 'context_window' => $model->context_window,
'max_output_tokens' => $model->max_output_tokens, 'max_output_tokens' => $model->max_output_tokens,
'supports_streaming' => true, // Default to true for now 'supports_streaming' => true, // Default to true for now
'supports_function_calling' => in_array($model->provider, ['openai', 'anthropic']), 'supports_function_calling' => in_array($model->provider, ['openai', 'anthropic']),
'pricing' => [ 'pricing' => [
'input_per_1k' => $model->input_price_per_1k, 'input_per_1k' => round($model->input_price_per_million / 1000, 6),
'output_per_1k' => $model->output_price_per_1k, 'output_per_1k' => round($model->output_price_per_million / 1000, 6),
'currency' => 'USD', 'currency' => 'USD',
], ],
]; ];
@@ -308,4 +310,14 @@ class ProviderController extends Controller
default => '#', 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));
}
} }

View File

@@ -51,7 +51,7 @@ class ApiKeyController extends Controller
$apiKeys = $query->paginate(20)->withQueryString(); $apiKeys = $query->paginate(20)->withQueryString();
$gatewayUsers = GatewayUser::orderBy('alias')->get(); $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() public function create()
{ {
$gatewayUsers = GatewayUser::orderBy('alias')->get(); $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', $token);
session()->flash('new_api_key_id', $apiKey->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.'); ->with('success', 'API Key created successfully! Make sure to copy it now - it won\'t be shown again.');
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -136,7 +136,7 @@ class ApiKeyController extends Controller
->limit(20) ->limit(20)
->get(); ->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 // Delete the API key from database
$apiKey->delete(); $apiKey->delete();
return redirect()->route('api-keys.index') return redirect()->route('keys.index')
->with('success', 'API Key revoked successfully'); ->with('success', 'API Key revoked successfully');
} catch (\Exception $e) { } catch (\Exception $e) {

View File

@@ -1,212 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Budget;
use App\Models\GatewayUser;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class BudgetController extends Controller
{
/**
* Display a listing of budgets
*/
public function index()
{
$budgets = Budget::withCount('gatewayUsers')
->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!');
}
}

View File

@@ -14,8 +14,7 @@ class GatewayUserController extends Controller
*/ */
public function index(Request $request) public function index(Request $request)
{ {
$query = GatewayUser::with('budget') $query = GatewayUser::withCount(['apiKeys', 'usageLogs']);
->withCount(['apiKeys', 'usageLogs']);
// Search // Search
if ($request->filled('search')) { if ($request->filled('search')) {
@@ -87,7 +86,7 @@ class GatewayUserController extends Controller
*/ */
public function show(string $userId) public function show(string $userId)
{ {
$user = GatewayUser::with(['apiKeys', 'budget']) $user = GatewayUser::with(['apiKeys'])
->findOrFail($userId); ->findOrFail($userId);
// Get usage statistics for last 30 days // Get usage statistics for last 30 days

View File

@@ -4,28 +4,42 @@ namespace App\Http\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Services\Budget\BudgetChecker;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class CheckBudget class CheckBudget
{ {
public function __construct(
private BudgetChecker $budgetChecker
) {}
/** /**
* Handle an incoming request. * Handle an incoming request.
* Check if gateway user has exceeded budget or is blocked.
* *
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$user = $request->user(); $user = $request->user(); // GatewayUser from API Guard
if ($user) { // Check if user is blocked
// Check budget before processing request if ($user && $user->isBlocked()) {
// Estimated cost is 0 for now, will be calculated after request return response()->json([
$this->budgetChecker->checkBudget($user, 0.0); '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); return $next($request);

View File

@@ -4,30 +4,47 @@ namespace App\Http\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Services\RateLimit\RateLimitChecker; use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class CheckRateLimit class CheckRateLimit
{ {
public function __construct(
private RateLimitChecker $rateLimitChecker
) {}
/** /**
* Handle an incoming request. * Handle an incoming request.
* Check rate limit for gateway user.
* *
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$user = $request->user(); $user = $request->user(); // GatewayUser from API Guard
if ($user) { if (!$user || !$user->rate_limit_per_hour) {
// Check rate limit before processing request return $next($request);
$this->rateLimitChecker->checkRateLimit($user); }
// Increment counter after successful check $key = 'rate_limit:' . $user->user_id;
$this->rateLimitChecker->incrementCounter($user); $requests = Cache::get($key, 0);
if ($requests >= $user->rate_limit_per_hour) {
$ttl = Cache::get($key . ':ttl', 3600);
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); return $next($request);

View File

@@ -2,7 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\LlmRequest; use App\Models\UsageLog;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@@ -19,7 +19,7 @@ class LogLlmRequest implements ShouldQueue
public int $maxExceptions = 3; public int $maxExceptions = 3;
public function __construct( public function __construct(
private int $userId, private string $userId, // Changed from int to string for gateway_user_id
private string $provider, private string $provider,
private string $model, private string $model,
private array $requestPayload, private array $requestPayload,
@@ -42,8 +42,9 @@ class LogLlmRequest implements ShouldQueue
public function handle(): void public function handle(): void
{ {
try { try {
LlmRequest::create([ UsageLog::create([
'user_id' => $this->userId, 'request_id' => $this->requestId,
'gateway_user_id' => $this->userId, // Changed from user_id
'provider' => $this->provider, 'provider' => $this->provider,
'model' => $this->model, 'model' => $this->model,
'request_payload' => $this->requestPayload, 'request_payload' => $this->requestPayload,
@@ -52,20 +53,17 @@ class LogLlmRequest implements ShouldQueue
'completion_tokens' => $this->completionTokens, 'completion_tokens' => $this->completionTokens,
'total_tokens' => $this->totalTokens, 'total_tokens' => $this->totalTokens,
'response_time_ms' => $this->responseTimeMs, 'response_time_ms' => $this->responseTimeMs,
'prompt_cost' => $this->promptCost, 'cost' => $this->totalCost, // UsageLog has single 'cost' field
'completion_cost' => $this->completionCost,
'total_cost' => $this->totalCost,
'status' => $this->status, 'status' => $this->status,
'error_message' => $this->errorMessage, 'error_message' => $this->errorMessage,
'http_status' => $this->httpStatus,
'ip_address' => $this->ipAddress, 'ip_address' => $this->ipAddress,
'user_agent' => $this->userAgent, 'user_agent' => $this->userAgent,
'request_id' => $this->requestId, 'timestamp' => now(), // UsageLog uses 'timestamp' instead of created_at
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Failed to log LLM request', [ Log::error('Failed to log LLM request to UsageLog', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'user_id' => $this->userId, 'gateway_user_id' => $this->userId,
'provider' => $this->provider, 'provider' => $this->provider,
'model' => $this->model, 'model' => $this->model,
'request_id' => $this->requestId, 'request_id' => $this->requestId,
@@ -78,7 +76,7 @@ class LogLlmRequest implements ShouldQueue
public function failed(\Throwable $exception): void public function failed(\Throwable $exception): void
{ {
Log::critical('LogLlmRequest job failed after all retries', [ Log::critical('LogLlmRequest job failed after all retries', [
'user_id' => $this->userId, 'gateway_user_id' => $this->userId,
'provider' => $this->provider, 'provider' => $this->provider,
'model' => $this->model, 'model' => $this->model,
'request_id' => $this->requestId, 'request_id' => $this->requestId,

View File

@@ -55,8 +55,6 @@ class Budget extends Model
return 'Unlimited'; return 'Unlimited';
} }
public function gatewayUsers() // Note: gateway_users have their own budget system (monthly_budget_limit, current_month_spending)
{ // and are not linked to this budgets table
return $this->hasMany(GatewayUser::class, 'budget_id', 'budget_id');
}
} }

View File

@@ -4,10 +4,12 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; 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 $table = 'gateway_users';
protected $primaryKey = 'user_id'; protected $primaryKey = 'user_id';
@@ -17,8 +19,10 @@ class GatewayUser extends Model
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'alias', 'alias',
'budget_id', 'monthly_budget_limit',
'spend', 'current_month_spending',
'budget_alert_threshold',
'rate_limit_per_hour',
'blocked', 'blocked',
'metadata', 'metadata',
]; ];
@@ -26,48 +30,78 @@ class GatewayUser extends Model
protected $casts = [ protected $casts = [
'metadata' => 'array', 'metadata' => 'array',
'blocked' => 'boolean', 'blocked' => 'boolean',
'spend' => 'decimal:2', 'monthly_budget_limit' => 'decimal:2',
'current_month_spending' => 'decimal:2',
'created_at' => 'datetime', 'created_at' => 'datetime',
'updated_at' => 'datetime', 'updated_at' => 'datetime',
]; ];
/** // Relationships
* 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.
*/
public function apiKeys() 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() public function usageLogs()
{ {
return $this->hasMany(UsageLog::class, 'user_id', 'user_id'); return $this->hasMany(UsageLog::class, 'gateway_user_id', 'user_id');
} }
/** // Scopes
* Scope a query to only include active users.
*/
public function scopeActive($query) public function scopeActive($query)
{ {
return $query->where('blocked', false); return $query->where('blocked', false);
} }
/**
* Scope a query to only include blocked users.
*/
public function scopeBlocked($query) public function scopeBlocked($query)
{ {
return $query->where('blocked', true); 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;
}
} }

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
class GatewayUserCredential extends Model
{
protected $fillable = [
'gateway_user_id',
'provider',
'api_key',
'organization_id',
'is_active',
'last_used_at',
'last_tested_at',
'test_status',
'test_error',
];
protected $hidden = ['api_key'];
protected $casts = [
'is_active' => '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,
]);
}
}

View File

@@ -17,7 +17,7 @@ class UsageLog extends Model
protected $fillable = [ protected $fillable = [
'request_id', 'request_id',
'user_id', 'gateway_user_id', // Changed from user_id
'api_key', 'api_key',
'model', 'model',
'provider', 'provider',
@@ -30,6 +30,11 @@ class UsageLog extends Model
'error_message', 'error_message',
'timestamp', 'timestamp',
'metadata', 'metadata',
'request_payload',
'response_payload',
'response_time_ms',
'ip_address',
'user_agent',
]; ];
protected $casts = [ protected $casts = [
@@ -39,16 +44,15 @@ class UsageLog extends Model
'cost' => 'decimal:6', 'cost' => 'decimal:6',
'timestamp' => 'datetime', 'timestamp' => 'datetime',
'metadata' => 'array', 'metadata' => 'array',
'request_payload' => 'array',
'response_payload' => 'array',
'response_time_ms' => 'integer',
]; ];
public function user() // Relationships
{
return $this->belongsTo(GatewayUser::class, 'user_id', 'user_id');
}
public function gatewayUser() 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() public function apiKey()
@@ -66,4 +70,19 @@ class UsageLog extends Model
{ {
return $query->where('status', 'failed'); 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);
}
} }

View File

@@ -2,6 +2,12 @@
namespace App\Providers; 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; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -19,6 +25,71 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void 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
";
});
} }
} }

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Providers;
use Dedoc\Scramble\Scramble;
use Illuminate\Support\ServiceProvider;
class ScrambleServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Scramble::extendOpenApi(function ($openApi) {
$openApi->servers = [
\Dedoc\Scramble\Support\Generator\Server::make(url('/api')),
];
});
}
}

View File

@@ -2,9 +2,9 @@
namespace App\Services\LLM; namespace App\Services\LLM;
use App\Models\User; use App\Models\GatewayUser;
use App\Models\UserProviderCredential; use App\Models\GatewayUserCredential;
use App\Exceptions\{ProviderException, InsufficientBudgetException, RateLimitExceededException}; use App\Exceptions\{ProviderException, InsufficientBudgetException};
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class GatewayService class GatewayService
@@ -17,19 +17,18 @@ class GatewayService
/** /**
* Process a chat completion request through the gateway * Process a chat completion request through the gateway
* *
* @param User $user * @param GatewayUser $user Gateway user making the request
* @param string $provider * @param string $provider Provider name (openai, anthropic, google, deepseek, mistral)
* @param string $model * @param string $model Model name
* @param array $messages * @param array $messages Chat messages
* @param array $options * @param array $options Optional parameters
* @param string|null $ipAddress * @param string|null $ipAddress Client IP address
* @param string|null $userAgent * @param string|null $userAgent Client user agent
* @return array * @return array Response with metadata
* @throws ProviderException * @throws ProviderException
* @throws InsufficientBudgetException
*/ */
public function chatCompletion( public function chatCompletion(
User $user, GatewayUser $user,
string $provider, string $provider,
string $model, string $model,
array $messages, array $messages,
@@ -39,13 +38,13 @@ class GatewayService
): array { ): array {
$startTime = microtime(true); $startTime = microtime(true);
// 1. Get user's API credentials // 1. Get user's API credentials for the provider
$credential = $this->getUserCredential($user, $provider); $credential = $this->getUserCredential($user, $provider);
// 2. Create provider instance // 2. Create provider instance
$providerInstance = ProviderFactory::create($provider, $credential->api_key); $providerInstance = ProviderFactory::create($provider, $credential->api_key);
// 3. Build request payload // 3. Build request payload for logging
$requestPayload = [ $requestPayload = [
'provider' => $provider, 'provider' => $provider,
'model' => $model, 'model' => $model,
@@ -54,16 +53,16 @@ class GatewayService
]; ];
try { try {
// 4. Make the API request // 4. Make the API request to LLM provider
$response = $providerInstance->chatCompletion($messages, array_merge($options, ['model' => $model])); $response = $providerInstance->chatCompletion($messages, array_merge($options, ['model' => $model]));
// 5. Normalize response // 5. Normalize response to standard format
$normalized = $providerInstance->normalizeResponse($response); $normalized = $providerInstance->normalizeResponse($response);
// 6. Calculate response time // 6. Calculate response time
$responseTimeMs = (int) round((microtime(true) - $startTime) * 1000); $responseTimeMs = (int) round((microtime(true) - $startTime) * 1000);
// 7. Calculate costs // 7. Calculate costs based on token usage
$costs = $this->costCalculator->calculate( $costs = $this->costCalculator->calculate(
$provider, $provider,
$normalized['model'], $normalized['model'],
@@ -71,9 +70,9 @@ class GatewayService
$normalized['usage']['completion_tokens'] $normalized['usage']['completion_tokens']
); );
// 8. Log request asynchronously // 8. Log successful request
$requestId = $this->requestLogger->logSuccess( $requestId = $this->requestLogger->logSuccess(
$user->id, $user->user_id, // Gateway user ID
$provider, $provider,
$normalized['model'], $normalized['model'],
$requestPayload, $requestPayload,
@@ -84,10 +83,10 @@ class GatewayService
$userAgent $userAgent
); );
// 9. Update user budget (synchronously for accuracy) // 9. Update user's spending budget
$this->updateUserBudget($user, $costs['total_cost']); $this->updateUserBudget($user, $costs['total_cost']);
// 10. Return response with metadata // 10. Return standardized response with metadata
return [ return [
'success' => true, 'success' => true,
'request_id' => $requestId, 'request_id' => $requestId,
@@ -102,9 +101,9 @@ class GatewayService
]; ];
} catch (ProviderException $e) { } catch (ProviderException $e) {
// Log failure // Log failed request
$this->requestLogger->logFailure( $this->requestLogger->logFailure(
$user->id, $user->user_id,
$provider, $provider,
$model, $model,
$requestPayload, $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('provider', $provider)
->where('is_active', true) ->where('is_active', true)
->first(); ->first();
@@ -143,30 +147,26 @@ class GatewayService
/** /**
* Update user's budget with spending * 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) { // Check if user should receive budget alert
return; // No budget configured 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); // Check if budget is now exceeded
$budget->increment('current_day_spending', $cost); if ($user->hasExceededBudget()) {
Log::warning("Budget exceeded: Gateway user {$user->user_id} has exceeded monthly budget");
// Check if budget exceeded
if ($budget->current_month_spending >= $budget->monthly_limit) {
$budget->update(['is_budget_exceeded' => true]);
}
// Check alert threshold
if ($budget->alert_threshold_percentage) {
$threshold = $budget->monthly_limit * ($budget->alert_threshold_percentage / 100);
if ($budget->current_month_spending >= $threshold && !$budget->last_alert_sent_at) {
// TODO: Dispatch alert notification
$budget->update(['last_alert_sent_at' => now()]);
}
} }
} }
} }

View File

@@ -9,9 +9,20 @@ class RequestLogger
{ {
/** /**
* Log a successful LLM request * 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( public function logSuccess(
int $userId, string $gatewayUserId,
string $provider, string $provider,
string $model, string $model,
array $requestPayload, array $requestPayload,
@@ -24,7 +35,7 @@ class RequestLogger
$requestId = $this->generateRequestId(); $requestId = $this->generateRequestId();
LogLlmRequest::dispatch( LogLlmRequest::dispatch(
userId: $userId, userId: $gatewayUserId,
provider: $provider, provider: $provider,
model: $model, model: $model,
requestPayload: $requestPayload, requestPayload: $requestPayload,
@@ -49,9 +60,19 @@ class RequestLogger
/** /**
* Log a failed LLM request * 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( public function logFailure(
int $userId, string $gatewayUserId,
string $provider, string $provider,
string $model, string $model,
array $requestPayload, array $requestPayload,
@@ -63,7 +84,7 @@ class RequestLogger
$requestId = $this->generateRequestId(); $requestId = $this->generateRequestId();
LogLlmRequest::dispatch( LogLlmRequest::dispatch(
userId: $userId, userId: $gatewayUserId,
provider: $provider, provider: $provider,
model: $model, model: $model,
requestPayload: $requestPayload, requestPayload: $requestPayload,

View File

@@ -2,5 +2,6 @@
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\ScrambleServiceProvider::class,
App\Providers\VoltServiceProvider::class, App\Providers\VoltServiceProvider::class,
]; ];

View File

@@ -40,6 +40,11 @@ return [
'driver' => 'session', 'driver' => 'session',
'provider' => 'users', 'provider' => 'users',
], ],
'api' => [
'driver' => 'api-key',
'provider' => 'gateway_users',
],
], ],
/* /*
@@ -70,6 +75,11 @@ return [
'model' => env('AUTH_MODEL', App\Models\User::class), 'model' => env('AUTH_MODEL', App\Models\User::class),
], ],
'gateway_users' => [
'driver' => 'eloquent',
'model' => App\Models\GatewayUser::class,
],
// 'users' => [ // 'users' => [
// 'driver' => 'database', // 'driver' => 'database',
// 'table' => 'users', // 'table' => 'users',

View File

@@ -29,7 +29,77 @@ return [
/* /*
* Description rendered on the home page of the API documentation (`/docs/api`). * 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. * Determines how Scramble stores the descriptions of enum cases.

View File

@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('gateway_users', function (Blueprint $table) {
// Add new budget fields
$table->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'
]);
});
}
};

View File

@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('gateway_user_credentials', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('usage_logs', function (Blueprint $table) {
// Rename user_id to gateway_user_id for clarity
$table->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);
}
}
});
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('api_keys', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* Drop budgets and user_budgets tables as budget functionality
* is now integrated directly in gateway_users table.
*/
public function up(): void
{
Schema::dropIfExists('budgets');
Schema::dropIfExists('user_budgets');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Recreate user_budgets table
Schema::create('user_budgets', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -0,0 +1,70 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* Rename user_id to gateway_user_id and change type from bigint to varchar(255)
*/
public function up(): void
{
// Step 1: Drop existing constraints and indexes
Schema::table('llm_requests', function (Blueprint $table) {
// Drop foreign key if exists (might not exist)
try {
$table->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');
});
}
};

View File

@@ -164,7 +164,7 @@
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-900">API Keys</h2> <h2 class="text-lg font-semibold text-gray-900">API Keys</h2>
{{-- TODO: Enable when API Keys Management is implemented --}} {{-- TODO: Enable when API Keys Management is implemented --}}
{{-- <a href="{{ route('api-keys.create', ['user_id' => $user->user_id]) }}" {{-- <a href="{{ route('keys.create', ['user_id' => $user->user_id]) }}"
class="text-sm text-indigo-600 hover:text-indigo-900"> class="text-sm text-indigo-600 hover:text-indigo-900">
+ Create Key + Create Key
</a> --}} </a> --}}

View File

@@ -4,7 +4,7 @@
<h2 class="font-semibold text-xl text-gray-800 leading-tight"> <h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Create New API Key') }} {{ __('Create New API Key') }}
</h2> </h2>
<a href="{{ route('api-keys.index') }}" <a href="{{ route('keys.index') }}"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"> class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
@@ -64,7 +64,7 @@
<!-- Create Form --> <!-- Create Form -->
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900"> <div class="p-6 text-gray-900">
<form method="POST" action="{{ route('api-keys.store') }}" class="space-y-6"> <form method="POST" action="{{ route('keys.store') }}" class="space-y-6">
@csrf @csrf
<!-- Key Name --> <!-- Key Name -->
@@ -167,7 +167,7 @@
<!-- Submit Buttons --> <!-- Submit Buttons -->
<div class="flex items-center justify-end space-x-4 pt-4"> <div class="flex items-center justify-end space-x-4 pt-4">
<a href="{{ route('api-keys.index') }}" <a href="{{ route('keys.index') }}"
class="inline-flex justify-center py-2 px-4 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> class="inline-flex justify-center py-2 px-4 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Cancel Cancel
</a> </a>

View File

@@ -4,7 +4,7 @@
<h2 class="font-semibold text-xl text-gray-800 leading-tight"> <h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('API Keys Management') }} {{ __('API Keys Management') }}
</h2> </h2>
<a href="{{ route('api-keys.create') }}" <a href="{{ route('keys.create') }}"
class="inline-flex items-center px-4 py-2 bg-blue-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-blue-700 focus:bg-blue-700 active:bg-blue-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition ease-in-out duration-150"> class="inline-flex items-center px-4 py-2 bg-blue-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-blue-700 focus:bg-blue-700 active:bg-blue-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition ease-in-out duration-150">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
@@ -44,7 +44,7 @@
<p>This is the only time you'll see this key. Copy it now:</p> <p>This is the only time you'll see this key. Copy it now:</p>
<div class="mt-2 flex items-center"> <div class="mt-2 flex items-center">
<code id="new-api-key" class="bg-white px-4 py-2 rounded border border-yellow-300 font-mono text-sm">{{ session('new_api_key') }}</code> <code id="new-api-key" class="bg-white px-4 py-2 rounded border border-yellow-300 font-mono text-sm">{{ session('new_api_key') }}</code>
<button onclick="copyToClipboard('new-api-key')" <button onclick="copyToClipboard('new-api-key', event)"
class="ml-2 px-3 py-2 bg-yellow-500 text-white rounded hover:bg-yellow-600"> class="ml-2 px-3 py-2 bg-yellow-500 text-white rounded hover:bg-yellow-600">
Copy Copy
</button> </button>
@@ -58,7 +58,7 @@
<!-- Filters --> <!-- Filters -->
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg mb-6"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg mb-6">
<div class="p-6"> <div class="p-6">
<form method="GET" action="{{ route('api-keys.index') }}" class="grid grid-cols-1 md:grid-cols-4 gap-4"> <form method="GET" action="{{ route('keys.index') }}" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- Search --> <!-- Search -->
<div> <div>
<label for="search" class="block text-sm font-medium text-gray-700 mb-1">Search</label> <label for="search" class="block text-sm font-medium text-gray-700 mb-1">Search</label>
@@ -183,10 +183,10 @@
{{ $key->created_at->format('Y-m-d H:i') }} {{ $key->created_at->format('Y-m-d H:i') }}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="{{ route('api-keys.show', $key->token) }}" <a href="{{ route('keys.show', $key->token) }}"
class="text-blue-600 hover:text-blue-900 mr-3">View</a> class="text-blue-600 hover:text-blue-900 mr-3">View</a>
@if($key->is_active && !$key->is_expired) @if($key->is_active && !$key->is_expired)
<form action="{{ route('api-keys.revoke', $key->token) }}" <form action="{{ route('keys.revoke', $key->token) }}"
method="POST" method="POST"
class="inline" class="inline"
onsubmit="return confirm('Are you sure you want to revoke this API key? This action cannot be undone.');"> onsubmit="return confirm('Are you sure you want to revoke this API key? This action cannot be undone.');">
@@ -215,7 +215,7 @@
<h3 class="mt-2 text-sm font-medium text-gray-900">No API keys found</h3> <h3 class="mt-2 text-sm font-medium text-gray-900">No API keys found</h3>
<p class="mt-1 text-sm text-gray-500">Get started by creating a new API key.</p> <p class="mt-1 text-sm text-gray-500">Get started by creating a new API key.</p>
<div class="mt-6"> <div class="mt-6">
<a href="{{ route('api-keys.create') }}" <a href="{{ route('keys.create') }}"
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<svg class="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
@@ -232,7 +232,7 @@
@push('scripts') @push('scripts')
<script> <script>
function copyToClipboard(elementId) { function copyToClipboard(elementId, event) {
const element = document.getElementById(elementId); const element = document.getElementById(elementId);
const text = element.textContent; const text = element.textContent;

View File

@@ -4,7 +4,7 @@
<h2 class="font-semibold text-xl text-gray-800 leading-tight"> <h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('API Key Details') }} {{ __('API Key Details') }}
</h2> </h2>
<a href="{{ route('api-keys.index') }}" <a href="{{ route('keys.index') }}"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"> class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
@@ -103,7 +103,7 @@
<!-- Action Buttons --> <!-- Action Buttons -->
@if($apiKey->is_active && !$apiKey->is_expired) @if($apiKey->is_active && !$apiKey->is_expired)
<div class="mt-6 pt-6 border-t border-gray-200"> <div class="mt-6 pt-6 border-t border-gray-200">
<form action="{{ route('api-keys.revoke', $apiKey->id) }}" <form action="{{ route('keys.revoke', $apiKey->id) }}"
method="POST" method="POST"
onsubmit="return confirm('Are you sure you want to revoke this API key? This action cannot be undone and all requests using this key will be rejected immediately.');"> onsubmit="return confirm('Are you sure you want to revoke this API key? This action cannot be undone and all requests using this key will be rejected immediately.');">
@csrf @csrf

View File

@@ -36,7 +36,7 @@ new class extends Component
<x-nav-link :href="route('gateway-users.index')" :active="request()->routeIs('gateway-users.*')" wire:navigate> <x-nav-link :href="route('gateway-users.index')" :active="request()->routeIs('gateway-users.*')" wire:navigate>
{{ __('Gateway Users') }} {{ __('Gateway Users') }}
</x-nav-link> </x-nav-link>
<x-nav-link :href="route('api-keys.index')" :active="request()->routeIs('api-keys.*')" wire:navigate> <x-nav-link :href="route('keys.index')" :active="request()->routeIs('keys.*')" wire:navigate>
{{ __('API Keys') }} {{ __('API Keys') }}
</x-nav-link> </x-nav-link>
<x-nav-link :href="route('budgets.index')" :active="request()->routeIs('budgets.*')" wire:navigate> <x-nav-link :href="route('budgets.index')" :active="request()->routeIs('budgets.*')" wire:navigate>
@@ -108,7 +108,7 @@ new class extends Component
<x-responsive-nav-link :href="route('gateway-users.index')" :active="request()->routeIs('gateway-users.*')" wire:navigate> <x-responsive-nav-link :href="route('gateway-users.index')" :active="request()->routeIs('gateway-users.*')" wire:navigate>
{{ __('Gateway Users') }} {{ __('Gateway Users') }}
</x-responsive-nav-link> </x-responsive-nav-link>
<x-responsive-nav-link :href="route('api-keys.index')" :active="request()->routeIs('api-keys.*')" wire:navigate> <x-responsive-nav-link :href="route('keys.index')" :active="request()->routeIs('keys.*')" wire:navigate>
{{ __('API Keys') }} {{ __('API Keys') }}
</x-responsive-nav-link> </x-responsive-nav-link>
<x-responsive-nav-link :href="route('budgets.index')" :active="request()->routeIs('budgets.*')" wire:navigate> <x-responsive-nav-link :href="route('budgets.index')" :active="request()->routeIs('budgets.*')" wire:navigate>

View File

@@ -187,7 +187,7 @@
modelHint.textContent = 'Loading models from ' + provider + '...'; modelHint.textContent = 'Loading models from ' + provider + '...';
try { try {
const response = await fetch(`/api/provider-models/${provider}`); const response = await fetch(`/admin/provider-models/${provider}`);
const data = await response.json(); const data = await response.json();
if (data.success && data.models) { if (data.success && data.models) {

View File

@@ -25,10 +25,10 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('gateway-users-bulk-action', [GatewayUserController::class, 'bulkAction']) Route::post('gateway-users-bulk-action', [GatewayUserController::class, 'bulkAction'])
->name('gateway-users.bulk-action'); ->name('gateway-users.bulk-action');
// API Keys Management // API Keys Management (Admin Interface)
Route::resource('api-keys', ApiKeyController::class)->except(['edit', 'update']); Route::resource('keys', ApiKeyController::class)->except(['edit', 'update']);
Route::post('api-keys/{id}/revoke', [ApiKeyController::class, 'revoke']) Route::post('keys/{id}/revoke', [ApiKeyController::class, 'revoke'])
->name('api-keys.revoke'); ->name('keys.revoke');
// Budgets Management // Budgets Management
Route::resource('budgets', BudgetController::class); Route::resource('budgets', BudgetController::class);
@@ -45,7 +45,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('model-pricing-calculate', [ModelPricingController::class, 'calculate'])->name('model-pricing.calculate'); Route::post('model-pricing-calculate', [ModelPricingController::class, 'calculate'])->name('model-pricing.calculate');
Route::get('model-pricing-import', [ModelPricingController::class, 'importForm'])->name('model-pricing.import-form'); Route::get('model-pricing-import', [ModelPricingController::class, 'importForm'])->name('model-pricing.import-form');
Route::post('model-pricing-import', [ModelPricingController::class, 'import'])->name('model-pricing.import'); Route::post('model-pricing-import', [ModelPricingController::class, 'import'])->name('model-pricing.import');
Route::get('api/provider-models/{provider}', [ModelPricingController::class, 'getProviderModels'])->name('api.provider-models'); Route::get('admin/provider-models/{provider}', [ModelPricingController::class, 'getProviderModels'])->name('api.provider-models');
// Provider Credentials Management (Admin) // Provider Credentials Management (Admin)
Route::prefix('admin')->name('admin.')->group(function () { Route::prefix('admin')->name('admin.')->group(function () {