Initial commit: Any-LLM Gateway with Laravel Admin Interface

- Any-LLM Gateway setup with Docker Compose
- Laravel 11 admin interface with Livewire
- Dashboard with usage statistics and charts
- Gateway Users management with budget tracking
- API Keys management with revocation
- Budget templates with assignment
- Usage Logs with filtering and CSV export
- Model Pricing management with calculator
- PostgreSQL database integration
- Complete authentication system for admins
This commit is contained in:
wtrinkl
2025-11-16 12:38:05 +01:00
commit b1363aeab9
148 changed files with 23995 additions and 0 deletions

View File

@@ -0,0 +1,201 @@
<?php
namespace App\Http\Controllers;
use App\Models\ApiKey;
use App\Models\GatewayUser;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class ApiKeyController extends Controller
{
/**
* Display a listing of the API keys.
*/
public function index(Request $request)
{
$query = ApiKey::with('gatewayUser');
// Filter by status
if ($request->has('status')) {
switch ($request->status) {
case 'active':
$query->active();
break;
case 'expired':
$query->expired();
break;
case 'inactive':
$query->where('is_active', false);
break;
}
}
// Filter by user
if ($request->has('user_id') && $request->user_id) {
$query->where('user_id', $request->user_id);
}
// Search by key name
if ($request->has('search') && $request->search) {
$query->where('key_name', 'like', '%' . $request->search . '%');
}
// Sort
$sortBy = $request->get('sort_by', 'created_at');
$sortOrder = $request->get('sort_order', 'desc');
$query->orderBy($sortBy, $sortOrder);
$apiKeys = $query->paginate(20)->withQueryString();
$gatewayUsers = GatewayUser::orderBy('alias')->get();
return view('api-keys.index', compact('apiKeys', 'gatewayUsers'));
}
/**
* Show the form for creating a new API key.
*/
public function create()
{
$gatewayUsers = GatewayUser::orderBy('alias')->get();
return view('api-keys.create', compact('gatewayUsers'));
}
/**
* Store a newly created API key.
*/
public function store(Request $request)
{
$validated = $request->validate([
'key_name' => 'required|string|max:255',
'user_id' => 'required|string|exists:users,user_id',
'expires_at' => 'nullable|date|after:now',
'metadata' => 'nullable|json',
]);
try {
// Get master key from config
$masterKey = env('GATEWAY_MASTER_KEY');
if (!$masterKey) {
return back()->with('error', 'Gateway Master Key not configured');
}
// Prepare request payload
$payload = [
'user_id' => $validated['user_id'],
'key_name' => $validated['key_name'],
];
// Add optional fields only if they have values
if (!empty($validated['expires_at'])) {
$payload['expires_at'] = $validated['expires_at'];
}
if (!empty($validated['metadata'])) {
$payload['metadata'] = json_decode($validated['metadata'], true) ?: new \stdClass();
}
// Create Virtual Key via Any-LLM Gateway API
$response = Http::withHeaders([
'X-AnyLLM-Key' => 'Bearer ' . $masterKey,
'Content-Type' => 'application/json',
])->post(env('GATEWAY_API_URL', 'http://gateway:8000') . '/v1/keys', $payload);
if (!$response->successful()) {
Log::error('Failed to create API key', [
'status' => $response->status(),
'body' => $response->body()
]);
return back()->with('error', 'Failed to create API key: ' . $response->body());
}
$data = $response->json();
// The actual key is only available once - store it in session for display
session()->flash('new_api_key', $data['key'] ?? null);
session()->flash('new_api_key_id', $data['id'] ?? null);
return redirect()->route('api-keys.index')
->with('success', 'API Key created successfully! Make sure to copy it now - it won\'t be shown again.');
} catch (\Exception $e) {
Log::error('Exception creating API key', ['error' => $e->getMessage()]);
return back()->with('error', 'Failed to create API key: ' . $e->getMessage());
}
}
/**
* Display the specified API key.
*/
public function show(string $id)
{
$apiKey = ApiKey::with(['gatewayUser', 'usageLogs'])->findOrFail($id);
// Get usage statistics
$stats = [
'total_requests' => $apiKey->usageLogs()->count(),
'total_cost' => $apiKey->usageLogs()->sum('cost'),
'total_tokens' => $apiKey->usageLogs()->sum('total_tokens'),
'last_30_days_requests' => $apiKey->usageLogs()
->where('timestamp', '>=', now()->subDays(30))
->count(),
];
// Get recent activity
$recentLogs = $apiKey->usageLogs()
->orderByDesc('timestamp')
->limit(20)
->get();
return view('api-keys.show', compact('apiKey', 'stats', 'recentLogs'));
}
/**
* Revoke the specified API key.
*/
public function revoke(string $id)
{
try {
$apiKey = ApiKey::findOrFail($id);
// Get master key from config
$masterKey = env('GATEWAY_MASTER_KEY');
if (!$masterKey) {
return back()->with('error', 'Gateway Master Key not configured');
}
// Revoke via Any-LLM Gateway API
$response = Http::withHeaders([
'X-AnyLLM-Key' => 'Bearer ' . $masterKey,
'Content-Type' => 'application/json',
])->delete(env('GATEWAY_API_URL', 'http://gateway:8000') . '/v1/keys/' . $id);
if (!$response->successful()) {
Log::error('Failed to revoke API key', [
'key_id' => $id,
'status' => $response->status(),
'body' => $response->body()
]);
return back()->with('error', 'Failed to revoke API key: ' . $response->body());
}
return redirect()->route('api-keys.index')
->with('success', 'API Key revoked successfully');
} catch (\Exception $e) {
Log::error('Exception revoking API key', ['error' => $e->getMessage()]);
return back()->with('error', 'Failed to revoke API key: ' . $e->getMessage());
}
}
/**
* Remove the specified API key.
*/
public function destroy(string $id)
{
// This is an alias for revoke
return $this->revoke($id);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View File

@@ -0,0 +1,179 @@
<?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',
]);
// Calculate budget_duration_sec based on type
$duration = match($validated['budget_type']) {
'daily' => 86400, // 1 day
'weekly' => 604800, // 7 days
'monthly' => 2592000, // 30 days
'custom' => ($validated['custom_duration_days'] ?? 1) * 86400,
'unlimited' => null,
};
$budget = Budget::create([
'budget_id' => 'budget-' . Str::uuid(),
'max_budget' => $validated['max_budget'],
'budget_duration_sec' => $duration,
]);
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([
'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',
]);
// Calculate budget_duration_sec based on type
$duration = match($validated['budget_type']) {
'daily' => 86400,
'weekly' => 604800,
'monthly' => 2592000,
'custom' => ($validated['custom_duration_days'] ?? 1) * 86400,
'unlimited' => null,
};
$budget->update([
'max_budget' => $validated['max_budget'],
'budget_duration_sec' => $duration,
]);
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

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers;
use App\Services\StatisticsService;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function __construct(
private StatisticsService $statsService
) {}
/**
* Display the dashboard
*/
public function index()
{
$stats = $this->statsService->getDashboardStats();
$dailyUsage = $this->statsService->getDailyUsageChart(30);
$topUsers = $this->statsService->getTopUsers(5);
$providerStats = $this->statsService->getUsageByProvider(30);
$modelStats = $this->statsService->getUsageByModel(30);
return view('dashboard', compact(
'stats',
'dailyUsage',
'topUsers',
'providerStats',
'modelStats'
));
}
}

View File

@@ -0,0 +1,242 @@
<?php
namespace App\Http\Controllers;
use App\Models\GatewayUser;
use App\Models\Budget;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class GatewayUserController extends Controller
{
/**
* Display a listing of the gateway users.
*/
public function index(Request $request)
{
$query = GatewayUser::with('budget')
->withCount(['apiKeys', 'usageLogs']);
// Search
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('user_id', 'like', "%{$search}%")
->orWhere('alias', 'like', "%{$search}%");
});
}
// Filter by status
if ($request->filled('status')) {
if ($request->status === 'active') {
$query->active();
} elseif ($request->status === 'blocked') {
$query->blocked();
}
}
// Sort
$sortField = $request->get('sort', 'created_at');
$sortDirection = $request->get('direction', 'desc');
$query->orderBy($sortField, $sortDirection);
$users = $query->paginate(20)->withQueryString();
return view('gateway-users.index', compact('users'));
}
/**
* Show the form for creating a new gateway user.
*/
public function create()
{
$budgets = Budget::all();
return view('gateway-users.create', compact('budgets'));
}
/**
* Store a newly created gateway user in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
'alias' => 'nullable|string|max:255',
'budget_id' => 'nullable|exists:budgets,budget_id',
'metadata' => 'nullable|array',
]);
// Generate unique user_id
$userId = 'user_' . Str::random(16);
$user = GatewayUser::create([
'user_id' => $userId,
'alias' => $validated['alias'] ?? null,
'budget_id' => $validated['budget_id'] ?? null,
'spend' => 0,
'blocked' => false,
'metadata' => $validated['metadata'] ?? [],
]);
return redirect()
->route('gateway-users.show', $user->user_id)
->with('success', 'Gateway User created successfully!');
}
/**
* Display the specified gateway user.
*/
public function show(string $userId)
{
$user = GatewayUser::with(['apiKeys', 'budget'])
->findOrFail($userId);
// Get usage statistics for last 30 days
$stats = $this->getUserStatistics($userId, 30);
// Get recent logs
$recentLogs = $user->usageLogs()
->with('apiKey')
->orderByDesc('timestamp')
->limit(50)
->get();
// Get daily usage for chart (last 30 days)
$dailyUsage = $user->usageLogs()
->selectRaw('DATE(timestamp) as date, COUNT(*) as requests, SUM(cost) as cost, SUM(total_tokens) as tokens')
->where('timestamp', '>=', now()->subDays(30))
->groupBy('date')
->orderBy('date')
->get();
return view('gateway-users.show', compact('user', 'stats', 'recentLogs', 'dailyUsage'));
}
/**
* Show the form for editing the specified gateway user.
*/
public function edit(string $userId)
{
$user = GatewayUser::findOrFail($userId);
$budgets = Budget::all();
return view('gateway-users.edit', compact('user', 'budgets'));
}
/**
* Update the specified gateway user in storage.
*/
public function update(Request $request, string $userId)
{
$user = GatewayUser::findOrFail($userId);
$validated = $request->validate([
'alias' => 'nullable|string|max:255',
'budget_id' => 'nullable|exists:budgets,budget_id',
'metadata' => 'nullable|array',
]);
$user->update($validated);
return redirect()
->route('gateway-users.show', $user->user_id)
->with('success', 'Gateway User updated successfully!');
}
/**
* Remove the specified gateway user from storage.
*/
public function destroy(string $userId)
{
$user = GatewayUser::findOrFail($userId);
// Delete associated API keys and usage logs
$user->apiKeys()->delete();
$user->usageLogs()->delete();
$user->delete();
return redirect()
->route('gateway-users.index')
->with('success', 'Gateway User deleted successfully!');
}
/**
* Toggle block status of a gateway user.
*/
public function toggleBlock(string $userId)
{
$user = GatewayUser::findOrFail($userId);
$user->blocked = !$user->blocked;
$user->save();
$status = $user->blocked ? 'blocked' : 'unblocked';
return redirect()
->back()
->with('success', "User has been {$status} successfully!");
}
/**
* Bulk actions for gateway users.
*/
public function bulkAction(Request $request)
{
$validated = $request->validate([
'action' => 'required|in:block,unblock,delete',
'user_ids' => 'required|array|min:1',
'user_ids.*' => 'exists:users,user_id',
]);
$count = 0;
switch ($validated['action']) {
case 'block':
GatewayUser::whereIn('user_id', $validated['user_ids'])
->update(['blocked' => true]);
$count = count($validated['user_ids']);
$message = "{$count} user(s) have been blocked successfully!";
break;
case 'unblock':
GatewayUser::whereIn('user_id', $validated['user_ids'])
->update(['blocked' => false]);
$count = count($validated['user_ids']);
$message = "{$count} user(s) have been unblocked successfully!";
break;
case 'delete':
foreach ($validated['user_ids'] as $userId) {
$user = GatewayUser::find($userId);
if ($user) {
$user->apiKeys()->delete();
$user->usageLogs()->delete();
$user->delete();
$count++;
}
}
$message = "{$count} user(s) have been deleted successfully!";
break;
}
return redirect()
->route('gateway-users.index')
->with('success', $message);
}
/**
* Get user statistics for a given time period.
*/
private function getUserStatistics(string $userId, int $days)
{
return \App\Models\UsageLog::where('user_id', $userId)
->where('timestamp', '>=', now()->subDays($days))
->selectRaw('
COUNT(*) as total_requests,
SUM(prompt_tokens) as total_prompt_tokens,
SUM(completion_tokens) as total_completion_tokens,
SUM(total_tokens) as total_tokens,
SUM(cost) as total_cost,
AVG(total_tokens) as avg_tokens_per_request
')
->first();
}
}

View File

@@ -0,0 +1,208 @@
<?php
namespace App\Http\Controllers;
use App\Models\ModelPricing;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class ModelPricingController extends Controller
{
/**
* Display a listing of model pricing
*/
public function index()
{
$modelPricing = ModelPricing::orderBy('model_key')
->paginate(20);
return view('model-pricing.index', compact('modelPricing'));
}
/**
* Show the form for creating a new model pricing
*/
public function create()
{
return view('model-pricing.create');
}
/**
* Store a newly created model pricing
*/
public function store(Request $request)
{
$validated = $request->validate([
'model_key' => 'required|string|max:255|unique:model_pricing,model_key',
'input_price_per_million' => 'required|numeric|min:0',
'output_price_per_million' => 'required|numeric|min:0',
]);
ModelPricing::create($validated);
return redirect()
->route('model-pricing.index')
->with('success', 'Model pricing created successfully!');
}
/**
* Display the specified model pricing
*/
public function show(string $modelKey)
{
$model = ModelPricing::findOrFail($modelKey);
return view('model-pricing.show', compact('model'));
}
/**
* Show the form for editing the specified model pricing
*/
public function edit(string $modelKey)
{
$model = ModelPricing::findOrFail($modelKey);
return view('model-pricing.edit', compact('model'));
}
/**
* Update the specified model pricing
*/
public function update(Request $request, string $modelKey)
{
$model = ModelPricing::findOrFail($modelKey);
$validated = $request->validate([
'input_price_per_million' => 'required|numeric|min:0',
'output_price_per_million' => 'required|numeric|min:0',
]);
$model->update($validated);
return redirect()
->route('model-pricing.index')
->with('success', 'Model pricing updated successfully!');
}
/**
* Remove the specified model pricing
*/
public function destroy(string $modelKey)
{
$model = ModelPricing::findOrFail($modelKey);
$model->delete();
return redirect()
->route('model-pricing.index')
->with('success', 'Model pricing deleted successfully!');
}
/**
* Show the cost calculator
*/
public function calculator()
{
$models = ModelPricing::orderBy('model_key')->get();
return view('model-pricing.calculator', compact('models'));
}
/**
* Calculate cost based on input
*/
public function calculate(Request $request)
{
$validated = $request->validate([
'model_key' => 'required|exists:model_pricing,model_key',
'input_tokens' => 'required|integer|min:0',
'output_tokens' => 'required|integer|min:0',
]);
$model = ModelPricing::findOrFail($validated['model_key']);
$cost = $model->calculateCost(
$validated['input_tokens'],
$validated['output_tokens']
);
return response()->json([
'model' => $model->model_key,
'input_tokens' => $validated['input_tokens'],
'output_tokens' => $validated['output_tokens'],
'total_tokens' => $validated['input_tokens'] + $validated['output_tokens'],
'cost' => $cost,
'cost_formatted' => '$' . number_format($cost, 6),
]);
}
/**
* Show CSV import form
*/
public function importForm()
{
return view('model-pricing.import');
}
/**
* Import model pricing from CSV
*/
public function import(Request $request)
{
$request->validate([
'csv_file' => 'required|file|mimes:csv,txt|max:2048',
]);
$file = $request->file('csv_file');
$handle = fopen($file->getRealPath(), 'r');
$imported = 0;
$updated = 0;
$errors = [];
// Skip header row
fgetcsv($handle);
while (($row = fgetcsv($handle)) !== false) {
if (count($row) < 3) {
continue; // Skip invalid rows
}
$modelKey = trim($row[0]);
$inputPrice = floatval($row[1]);
$outputPrice = floatval($row[2]);
if (empty($modelKey) || $inputPrice < 0 || $outputPrice < 0) {
$errors[] = "Invalid data for model: {$modelKey}";
continue;
}
$existing = ModelPricing::find($modelKey);
if ($existing) {
$existing->update([
'input_price_per_million' => $inputPrice,
'output_price_per_million' => $outputPrice,
]);
$updated++;
} else {
ModelPricing::create([
'model_key' => $modelKey,
'input_price_per_million' => $inputPrice,
'output_price_per_million' => $outputPrice,
]);
$imported++;
}
}
fclose($handle);
$message = "Import completed! Created: {$imported}, Updated: {$updated}";
if (count($errors) > 0) {
$message .= '. Errors: ' . implode(', ', array_slice($errors, 0, 5));
}
return redirect()
->route('model-pricing.index')
->with('success', $message);
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace App\Http\Controllers;
use App\Models\UsageLog;
use App\Models\GatewayUser;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class UsageLogController extends Controller
{
/**
* Display a listing of usage logs with filters
*/
public function index(Request $request)
{
$query = UsageLog::with(['gatewayUser', 'apiKey']);
// Date Range Filter
if ($request->filled('date_from')) {
$query->where('timestamp', '>=', $request->date_from . ' 00:00:00');
}
if ($request->filled('date_to')) {
$query->where('timestamp', '<=', $request->date_to . ' 23:59:59');
}
// User Filter
if ($request->filled('user_id')) {
$query->where('user_id', $request->user_id);
}
// Provider Filter
if ($request->filled('provider')) {
$query->where('provider', $request->provider);
}
// Model Filter
if ($request->filled('model')) {
$query->where('model', $request->model);
}
// Status Filter
if ($request->filled('status')) {
if ($request->status === 'success') {
$query->success();
} elseif ($request->status === 'failed') {
$query->failed();
}
}
// Get filter options for dropdowns
$users = GatewayUser::select('user_id', 'alias')
->orderBy('alias')
->get();
$providers = UsageLog::select('provider')
->distinct()
->whereNotNull('provider')
->orderBy('provider')
->pluck('provider');
$models = UsageLog::select('model')
->distinct()
->whereNotNull('model')
->orderBy('model')
->pluck('model');
// Get summary statistics for current filter
$summary = $query->clone()
->selectRaw('
COUNT(*) as total_requests,
SUM(CASE WHEN status = \'success\' THEN 1 ELSE 0 END) as successful_requests,
SUM(prompt_tokens) as total_prompt_tokens,
SUM(completion_tokens) as total_completion_tokens,
SUM(total_tokens) as total_tokens,
SUM(cost) as total_cost
')
->first();
// Paginate results
$logs = $query->orderByDesc('timestamp')
->paginate(50)
->withQueryString(); // Preserve query parameters
return view('usage-logs.index', compact(
'logs',
'users',
'providers',
'models',
'summary'
));
}
/**
* Export usage logs to CSV
*/
public function export(Request $request)
{
$query = UsageLog::with(['gatewayUser', 'apiKey']);
// Apply same filters as index
if ($request->filled('date_from')) {
$query->where('timestamp', '>=', $request->date_from . ' 00:00:00');
}
if ($request->filled('date_to')) {
$query->where('timestamp', '<=', $request->date_to . ' 23:59:59');
}
if ($request->filled('user_id')) {
$query->where('user_id', $request->user_id);
}
if ($request->filled('provider')) {
$query->where('provider', $request->provider);
}
if ($request->filled('model')) {
$query->where('model', $request->model);
}
if ($request->filled('status')) {
if ($request->status === 'success') {
$query->success();
} elseif ($request->status === 'failed') {
$query->failed();
}
}
// Limit export to 10,000 records for performance
$logs = $query->orderByDesc('timestamp')
->limit(10000)
->get();
$filename = 'usage-logs-' . now()->format('Y-m-d-His') . '.csv';
$headers = [
'Content-Type' => 'text/csv',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
];
$callback = function() use ($logs) {
$file = fopen('php://output', 'w');
// CSV Header
fputcsv($file, [
'Timestamp',
'User ID',
'User Alias',
'API Key',
'Provider',
'Model',
'Endpoint',
'Prompt Tokens',
'Completion Tokens',
'Total Tokens',
'Cost',
'Status',
'Error Message'
]);
// CSV Rows
foreach ($logs as $log) {
fputcsv($file, [
$log->timestamp->format('Y-m-d H:i:s'),
$log->user_id,
$log->gatewayUser?->alias ?? 'N/A',
$log->api_key_id,
$log->provider,
$log->model,
$log->endpoint,
$log->prompt_tokens,
$log->completion_tokens,
$log->total_tokens,
$log->cost,
$log->status,
$log->error_message ?? ''
]);
}
fclose($file);
};
return response()->stream($callback, 200, $headers);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Livewire\Actions;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
class Logout
{
/**
* Log the current user out of the application.
*/
public function __invoke(): void
{
Auth::guard('web')->logout();
Session::invalidate();
Session::regenerateToken();
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Livewire\Forms;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Validate;
use Livewire\Form;
class LoginForm extends Form
{
#[Validate('required|string|email')]
public string $email = '';
#[Validate('required|string')]
public string $password = '';
#[Validate('boolean')]
public bool $remember = false;
/**
* Attempt to authenticate the request's credentials.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only(['email', 'password']), $this->remember)) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'form.email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the authentication request is not rate limited.
*/
protected function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout(request()));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'form.email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the authentication rate limiting throttle key.
*/
protected function throttleKey(): string
{
return Str::transliterate(Str::lower($this->email).'|'.request()->ip());
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class Admin extends Authenticatable
{
use Notifiable;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var array<int, string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ApiKey extends Model
{
protected $primaryKey = 'id';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'id',
'key_hash',
'key_name',
'user_id',
'last_used_at',
'expires_at',
'is_active',
'metadata',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'metadata' => 'array',
'created_at' => 'datetime',
'last_used_at' => 'datetime',
'expires_at' => 'datetime',
];
}
public function gatewayUser()
{
return $this->belongsTo(GatewayUser::class, 'user_id', 'user_id');
}
public function usageLogs()
{
return $this->hasMany(UsageLog::class, 'api_key_id', 'id');
}
public function scopeActive($query)
{
return $query->where('is_active', true)
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
public function scopeExpired($query)
{
return $query->whereNotNull('expires_at')
->where('expires_at', '<=', now());
}
public function getMaskedKeyAttribute()
{
return 'gw-' . substr($this->id, 0, 8) . '...' . substr($this->id, -8);
}
public function getIsExpiredAttribute()
{
return $this->expires_at && $this->expires_at->isPast();
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Budget extends Model
{
protected $primaryKey = 'budget_id';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'budget_id',
'max_budget',
'budget_duration_sec',
];
protected function casts(): array
{
return [
'max_budget' => 'double',
'budget_duration_sec' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
public function gatewayUsers()
{
return $this->hasMany(GatewayUser::class, 'budget_id', 'budget_id');
}
public function getMaxBudgetFormattedAttribute()
{
return '$' . number_format($this->max_budget, 2);
}
public function getDurationHumanAttribute()
{
if (!$this->budget_duration_sec) return 'No limit';
$days = floor($this->budget_duration_sec / 86400);
$hours = floor(($this->budget_duration_sec % 86400) / 3600);
return "{$days}d {$hours}h";
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class GatewayUser extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'users';
/**
* The primary key for the model.
*
* @var string
*/
protected $primaryKey = 'user_id';
/**
* Indicates if the IDs are auto-incrementing.
*
* @var bool
*/
public $incrementing = false;
/**
* The data type of the primary key ID.
*
* @var string
*/
protected $keyType = 'string';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'alias',
'spend',
'budget_id',
'blocked',
'metadata',
'budget_started_at',
'next_budget_reset_at',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'spend' => 'double',
'blocked' => 'boolean',
'metadata' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'budget_started_at' => 'datetime',
'next_budget_reset_at' => 'datetime',
];
}
/**
* Get the API keys for this user.
*/
public function apiKeys()
{
return $this->hasMany(ApiKey::class, 'user_id', 'user_id');
}
/**
* Get the usage logs for this user.
*/
public function usageLogs()
{
return $this->hasMany(UsageLog::class, 'user_id', 'user_id');
}
/**
* Get the budget for this user.
*/
public function budget()
{
return $this->belongsTo(Budget::class, 'budget_id', 'budget_id');
}
/**
* Scope a query to only include active users.
*/
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);
}
/**
* Get the formatted spend amount.
*/
public function getSpendFormattedAttribute()
{
return '$' . number_format($this->spend, 2);
}
/**
* Get the total number of requests.
*/
public function getTotalRequestsAttribute()
{
return $this->usageLogs()->count();
}
/**
* Get the total number of tokens used.
*/
public function getTotalTokensAttribute()
{
return $this->usageLogs()->sum('total_tokens');
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ModelPricing extends Model
{
protected $table = 'model_pricing';
protected $primaryKey = 'model_key';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'model_key',
'input_price_per_million',
'output_price_per_million',
];
protected function casts(): array
{
return [
'input_price_per_million' => 'double',
'output_price_per_million' => 'double',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
// Accessors
public function getInputPriceFormattedAttribute()
{
return '$' . number_format($this->input_price_per_million, 2) . '/M';
}
public function getOutputPriceFormattedAttribute()
{
return '$' . number_format($this->output_price_per_million, 2) . '/M';
}
/**
* Calculate cost for given token counts
*/
public function calculateCost($inputTokens, $outputTokens)
{
$inputCost = ($inputTokens / 1000000) * $this->input_price_per_million;
$outputCost = ($outputTokens / 1000000) * $this->output_price_per_million;
return $inputCost + $outputCost;
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UsageLog extends Model
{
protected $primaryKey = 'id';
public $incrementing = false;
protected $keyType = 'string';
public $timestamps = false;
protected $fillable = [
'id',
'api_key_id',
'user_id',
'timestamp',
'model',
'provider',
'endpoint',
'prompt_tokens',
'completion_tokens',
'total_tokens',
'cost',
'status',
'error_message',
];
protected function casts(): array
{
return [
'timestamp' => 'datetime',
'prompt_tokens' => 'integer',
'completion_tokens' => 'integer',
'total_tokens' => 'integer',
'cost' => 'double',
];
}
public function gatewayUser()
{
return $this->belongsTo(GatewayUser::class, 'user_id', 'user_id');
}
public function apiKey()
{
return $this->belongsTo(ApiKey::class, 'api_key_id', 'id');
}
public function scopeSuccess($query)
{
return $query->where('status', 'success');
}
public function scopeFailed($query)
{
return $query->where('status', '!=', 'success');
}
public function scopeToday($query)
{
return $query->whereDate('timestamp', today());
}
public function scopeDateRange($query, $start, $end)
{
return $query->whereBetween('timestamp', [$start, $end]);
}
public function getCostFormattedAttribute()
{
return $this->cost ? '$' . number_format($this->cost, 4) : 'N/A';
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Livewire\Volt\Volt;
class VoltServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
//
}
/**
* Bootstrap services.
*/
public function boot(): void
{
Volt::mount([
config('livewire.view_path', resource_path('views/livewire')),
resource_path('views/pages'),
]);
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Services;
use App\Models\UsageLog;
use App\Models\GatewayUser;
use Illuminate\Support\Facades\DB;
class StatisticsService
{
/**
* Get dashboard overview statistics
*/
public function getDashboardStats()
{
return [
'total_users' => GatewayUser::count(),
'active_users' => GatewayUser::active()->count(),
'blocked_users' => GatewayUser::blocked()->count(),
'total_requests_today' => UsageLog::today()->count(),
'total_spend_today' => UsageLog::today()->sum('cost') ?? 0,
'total_tokens_today' => UsageLog::today()->sum('total_tokens') ?? 0,
'total_spend_month' => UsageLog::whereMonth('timestamp', now()->month)->sum('cost') ?? 0,
'total_requests_month' => UsageLog::whereMonth('timestamp', now()->month)->count(),
];
}
/**
* Get usage breakdown by provider
*/
public function getUsageByProvider($days = 30)
{
return UsageLog::selectRaw('provider, COUNT(*) as count, SUM(cost) as total_cost')
->where('timestamp', '>=', now()->subDays($days))
->groupBy('provider')
->orderByDesc('count')
->get();
}
/**
* Get usage breakdown by model
*/
public function getUsageByModel($days = 30)
{
return UsageLog::selectRaw('model, COUNT(*) as count, SUM(total_tokens) as tokens, SUM(cost) as total_cost')
->where('timestamp', '>=', now()->subDays($days))
->groupBy('model')
->orderByDesc('count')
->limit(10)
->get();
}
/**
* Get daily usage chart data
*/
public function getDailyUsageChart($days = 30)
{
return UsageLog::selectRaw('DATE(timestamp) as date, COUNT(*) as requests, SUM(cost) as cost, SUM(total_tokens) as tokens')
->where('timestamp', '>=', now()->subDays($days))
->groupBy('date')
->orderBy('date')
->get();
}
/**
* Get top users by spend
*/
public function getTopUsers($limit = 10)
{
return GatewayUser::withCount('usageLogs')
->withSum('usageLogs', 'cost')
->orderByDesc('usage_logs_sum_cost')
->limit($limit)
->get();
}
/**
* Get recent activity
*/
public function getRecentActivity($limit = 20)
{
return UsageLog::with(['gatewayUser', 'apiKey'])
->orderByDesc('timestamp')
->limit($limit)
->get();
}
/**
* Get user statistics
*/
public function getUserStatistics($userId, $days = 30)
{
return UsageLog::where('user_id', $userId)
->where('timestamp', '>=', now()->subDays($days))
->selectRaw('
COUNT(*) as total_requests,
SUM(prompt_tokens) as total_prompt_tokens,
SUM(completion_tokens) as total_completion_tokens,
SUM(total_tokens) as total_tokens,
SUM(cost) as total_cost,
AVG(total_tokens) as avg_tokens_per_request
')
->first();
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class AppLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.app');
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class GuestLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.guest');
}
}