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
479 lines
15 KiB
PHP
479 lines
15 KiB
PHP
<?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()
|
|
];
|
|
}
|
|
}
|
|
}
|