Add complete Laravel LLM Gateway implementation

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

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

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

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

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

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

View File

@@ -0,0 +1,478 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\UserProviderCredential;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Http;
class CredentialController extends Controller
{
/**
* Display a listing of provider credentials.
*/
public function index(Request $request)
{
$query = UserProviderCredential::with('user');
// Filter by provider
if ($request->has('provider') && $request->provider) {
$query->where('provider', $request->provider);
}
// Filter by user
if ($request->has('user_id') && $request->user_id) {
$query->where('user_id', $request->user_id);
}
// Filter by status
if ($request->has('status')) {
switch ($request->status) {
case 'active':
$query->where('is_active', true);
break;
case 'inactive':
$query->where('is_active', false);
break;
}
}
// Search
if ($request->has('search') && $request->search) {
$query->whereHas('user', function ($q) use ($request) {
$q->where('name', 'like', '%' . $request->search . '%')
->orWhere('email', 'like', '%' . $request->search . '%');
});
}
// Sort
$sortBy = $request->get('sort_by', 'created_at');
$sortOrder = $request->get('sort_order', 'desc');
$query->orderBy($sortBy, $sortOrder);
$credentials = $query->paginate(20)->withQueryString();
// Get all users and providers for filters
$users = User::orderBy('name')->get();
$providers = ['openai', 'anthropic', 'mistral', 'gemini', 'deepseek'];
return view('admin.credentials.index', compact('credentials', 'users', 'providers'));
}
/**
* Show the form for creating a new credential.
*/
public function create()
{
$users = User::orderBy('name')->get();
$providers = [
'openai' => 'OpenAI',
'anthropic' => 'Anthropic (Claude)',
'mistral' => 'Mistral AI',
'gemini' => 'Google Gemini',
'deepseek' => 'DeepSeek'
];
return view('admin.credentials.create', compact('users', 'providers'));
}
/**
* Store a newly created credential.
*/
public function store(Request $request)
{
$validated = $request->validate([
'user_id' => 'required|exists:users,id',
'provider' => 'required|in:openai,anthropic,mistral,gemini,deepseek',
'api_key' => 'required|string|min:10',
'organization_id' => 'nullable|string|max:255',
'is_active' => 'boolean',
]);
try {
// Check for duplicate
$existing = UserProviderCredential::where('user_id', $validated['user_id'])
->where('provider', $validated['provider'])
->first();
if ($existing) {
return back()
->withInput()
->with('error', 'This user already has credentials for ' . ucfirst($validated['provider']) . '. Please edit the existing credentials instead.');
}
// Create credential (encryption happens automatically in model)
$credential = UserProviderCredential::create([
'user_id' => $validated['user_id'],
'provider' => $validated['provider'],
'api_key' => $validated['api_key'],
'organization_id' => $validated['organization_id'] ?? null,
'is_active' => $validated['is_active'] ?? true,
]);
return redirect()->route('admin.credentials.index')
->with('success', 'Provider credentials added successfully!');
} catch (\Exception $e) {
Log::error('Failed to create provider credential', [
'error' => $e->getMessage(),
'user_id' => $validated['user_id'],
'provider' => $validated['provider']
]);
return back()
->withInput()
->with('error', 'Failed to add credentials: ' . $e->getMessage());
}
}
/**
* Display the specified credential.
*/
public function show(UserProviderCredential $credential)
{
$credential->load('user');
// Get usage statistics
$stats = [
'total_requests' => $credential->user->llmRequests()
->where('provider', $credential->provider)
->count(),
'total_cost' => $credential->user->llmRequests()
->where('provider', $credential->provider)
->sum('total_cost'),
'total_tokens' => $credential->user->llmRequests()
->where('provider', $credential->provider)
->sum('total_tokens'),
'last_30_days_requests' => $credential->user->llmRequests()
->where('provider', $credential->provider)
->where('created_at', '>=', now()->subDays(30))
->count(),
];
return view('admin.credentials.show', compact('credential', 'stats'));
}
/**
* Show the form for editing the specified credential.
*/
public function edit(UserProviderCredential $credential)
{
$credential->load('user');
$providers = [
'openai' => 'OpenAI',
'anthropic' => 'Anthropic (Claude)',
'mistral' => 'Mistral AI',
'gemini' => 'Google Gemini',
'deepseek' => 'DeepSeek'
];
return view('admin.credentials.edit', compact('credential', 'providers'));
}
/**
* Update the specified credential.
*/
public function update(Request $request, UserProviderCredential $credential)
{
$validated = $request->validate([
'api_key' => 'nullable|string|min:10',
'organization_id' => 'nullable|string|max:255',
'is_active' => 'boolean',
]);
try {
// Only update API key if provided
if (!empty($validated['api_key'])) {
$credential->api_key = $validated['api_key'];
}
$credential->organization_id = $validated['organization_id'] ?? null;
$credential->is_active = $validated['is_active'] ?? true;
$credential->save();
return redirect()->route('admin.credentials.index')
->with('success', 'Provider credentials updated successfully!');
} catch (\Exception $e) {
Log::error('Failed to update provider credential', [
'error' => $e->getMessage(),
'credential_id' => $credential->id
]);
return back()
->withInput()
->with('error', 'Failed to update credentials: ' . $e->getMessage());
}
}
/**
* Remove the specified credential.
*/
public function destroy(UserProviderCredential $credential)
{
try {
$provider = ucfirst($credential->provider);
$userName = $credential->user->name;
$credential->delete();
return redirect()->route('admin.credentials.index')
->with('success', "Provider credentials for {$provider} (User: {$userName}) deleted successfully!");
} catch (\Exception $e) {
Log::error('Failed to delete provider credential', [
'error' => $e->getMessage(),
'credential_id' => $credential->id
]);
return back()
->with('error', 'Failed to delete credentials: ' . $e->getMessage());
}
}
/**
* Test the API key validity.
*/
public function test(UserProviderCredential $credential)
{
try {
$result = $this->testProviderApiKey($credential->provider, $credential->api_key);
if ($result['success']) {
return response()->json([
'success' => true,
'message' => 'API key is valid and working!',
'details' => $result['details'] ?? null
]);
} else {
return response()->json([
'success' => false,
'message' => $result['message'] ?? 'API key validation failed',
'error' => $result['error'] ?? null
], 400);
}
} catch (\Exception $e) {
Log::error('Failed to test provider credential', [
'error' => $e->getMessage(),
'credential_id' => $credential->id
]);
return response()->json([
'success' => false,
'message' => 'Test failed: ' . $e->getMessage()
], 500);
}
}
/**
* Test provider API key validity
*/
private function testProviderApiKey(string $provider, string $apiKey): array
{
switch ($provider) {
case 'openai':
return $this->testOpenAI($apiKey);
case 'anthropic':
return $this->testAnthropic($apiKey);
case 'mistral':
return $this->testMistral($apiKey);
case 'gemini':
return $this->testGemini($apiKey);
case 'deepseek':
return $this->testDeepSeek($apiKey);
default:
return [
'success' => false,
'message' => 'Unsupported provider'
];
}
}
private function testOpenAI(string $apiKey): array
{
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json',
])->timeout(10)->post('https://api.openai.com/v1/chat/completions', [
'model' => 'gpt-3.5-turbo',
'messages' => [
['role' => 'user', 'content' => 'test']
],
'max_tokens' => 5
]);
if ($response->successful()) {
return [
'success' => true,
'details' => 'Model: gpt-3.5-turbo accessible'
];
}
return [
'success' => false,
'message' => 'Invalid API key or insufficient permissions',
'error' => $response->body()
];
} catch (\Exception $e) {
return [
'success' => false,
'message' => 'Connection failed',
'error' => $e->getMessage()
];
}
}
private function testAnthropic(string $apiKey): array
{
try {
$response = Http::withHeaders([
'x-api-key' => $apiKey,
'anthropic-version' => '2023-06-01',
'Content-Type' => 'application/json',
])->timeout(10)->post('https://api.anthropic.com/v1/messages', [
'model' => 'claude-3-haiku-20240307',
'max_tokens' => 10,
'messages' => [
['role' => 'user', 'content' => 'test']
]
]);
if ($response->successful()) {
return [
'success' => true,
'details' => 'Model: Claude 3 Haiku accessible'
];
}
return [
'success' => false,
'message' => 'Invalid API key or insufficient permissions',
'error' => $response->body()
];
} catch (\Exception $e) {
return [
'success' => false,
'message' => 'Connection failed',
'error' => $e->getMessage()
];
}
}
private function testMistral(string $apiKey): array
{
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json',
])->timeout(10)->post('https://api.mistral.ai/v1/chat/completions', [
'model' => 'mistral-tiny',
'messages' => [
['role' => 'user', 'content' => 'test']
],
'max_tokens' => 5
]);
if ($response->successful()) {
return [
'success' => true,
'details' => 'Model: Mistral Tiny accessible'
];
}
return [
'success' => false,
'message' => 'Invalid API key or insufficient permissions',
'error' => $response->body()
];
} catch (\Exception $e) {
return [
'success' => false,
'message' => 'Connection failed',
'error' => $e->getMessage()
];
}
}
private function testGemini(string $apiKey): array
{
try {
$response = Http::timeout(10)
->post("https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key={$apiKey}", [
'contents' => [
['parts' => [['text' => 'test']]]
]
]);
if ($response->successful()) {
return [
'success' => true,
'details' => 'Model: Gemini Pro accessible'
];
}
return [
'success' => false,
'message' => 'Invalid API key or insufficient permissions',
'error' => $response->body()
];
} catch (\Exception $e) {
return [
'success' => false,
'message' => 'Connection failed',
'error' => $e->getMessage()
];
}
}
private function testDeepSeek(string $apiKey): array
{
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json',
])->timeout(10)->post('https://api.deepseek.com/v1/chat/completions', [
'model' => 'deepseek-chat',
'messages' => [
['role' => 'user', 'content' => 'test']
],
'max_tokens' => 5
]);
if ($response->successful()) {
return [
'success' => true,
'details' => 'Model: DeepSeek Chat accessible'
];
}
return [
'success' => false,
'message' => 'Invalid API key or insufficient permissions',
'error' => $response->body()
];
} catch (\Exception $e) {
return [
'success' => false,
'message' => 'Connection failed',
'error' => $e->getMessage()
];
}
}
}

View File

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

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
class UserManagementController extends Controller
{
/**
* Display a listing of users with budget info
*/
public function index(Request $request)
{
$query = User::with(['budget', 'rateLimit', 'llmRequests'])
->withCount('llmRequests');
// Search
if ($request->has('search') && $request->search) {
$query->where(function ($q) use ($request) {
$q->where('name', 'like', '%' . $request->search . '%')
->orWhere('email', 'like', '%' . $request->search . '%');
});
}
// Sort
$sortBy = $request->get('sort_by', 'created_at');
$sortOrder = $request->get('sort_order', 'desc');
$query->orderBy($sortBy, $sortOrder);
$users = $query->paginate(20)->withQueryString();
return view('admin.users.index', compact('users'));
}
}