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,27 @@
<?php
namespace App\Exceptions;
use Exception;
class InsufficientBudgetException extends Exception
{
protected $code = 402; // Payment Required
public function __construct(string $message = "Insufficient budget", int $code = 402, ?Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
/**
* Render the exception as an HTTP response.
*/
public function render()
{
return response()->json([
'success' => false,
'error' => 'budget_exceeded',
'message' => $this->getMessage(),
], $this->code);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Exceptions;
use Exception;
class ProviderException extends Exception
{
public function __construct(string $message = "Provider error", int $code = 500, ?Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
/**
* Render the exception as an HTTP response.
*/
public function render()
{
return response()->json([
'success' => false,
'error' => 'provider_error',
'message' => $this->getMessage(),
], $this->code);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Exceptions;
use Exception;
class RateLimitExceededException extends Exception
{
protected $code = 429; // Too Many Requests
public function __construct(string $message = "Rate limit exceeded", int $code = 429, ?Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
/**
* Render the exception as an HTTP response.
*/
public function render()
{
return response()->json([
'success' => false,
'error' => 'rate_limit_exceeded',
'message' => $this->getMessage(),
], $this->code);
}
}

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'));
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\ChatCompletionRequest;
use App\Services\LLM\GatewayService;
use App\Exceptions\{ProviderException, InsufficientBudgetException, RateLimitExceededException};
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
class ChatCompletionController extends Controller
{
public function __construct(
private GatewayService $gatewayService
) {}
/**
* Handle chat completion request
*
* @param ChatCompletionRequest $request
* @return JsonResponse
*/
public function create(ChatCompletionRequest $request): JsonResponse
{
try {
$user = $request->user();
$result = $this->gatewayService->chatCompletion(
user: $user,
provider: $request->input('provider'),
model: $request->input('model'),
messages: $request->input('messages'),
options: $request->only(['temperature', 'max_tokens', 'top_p', 'frequency_penalty', 'presence_penalty', 'stop']),
ipAddress: $request->ip(),
userAgent: $request->userAgent()
);
return response()->json($result, 200);
} catch (InsufficientBudgetException $e) {
return response()->json([
'success' => false,
'error' => 'budget_exceeded',
'message' => $e->getMessage(),
], 402); // Payment Required
} catch (RateLimitExceededException $e) {
return response()->json([
'success' => false,
'error' => 'rate_limit_exceeded',
'message' => $e->getMessage(),
'retry_after' => $e->getRetryAfter(),
], 429);
} catch (ProviderException $e) {
Log::error('Provider error in chat completion', [
'user_id' => $request->user()->id,
'provider' => $request->input('provider'),
'error' => $e->getMessage(),
]);
return response()->json([
'success' => false,
'error' => 'provider_error',
'message' => $e->getMessage(),
], $e->getCode() ?: 500);
} catch (\Exception $e) {
Log::error('Unexpected error in chat completion', [
'user_id' => $request->user()->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'success' => false,
'error' => 'internal_error',
'message' => 'An unexpected error occurred. Please try again.',
], 500);
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Services\Budget\BudgetChecker;
use Symfony\Component\HttpFoundation\Response;
class CheckBudget
{
public function __construct(
private BudgetChecker $budgetChecker
) {}
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user) {
// Check budget before processing request
// Estimated cost is 0 for now, will be calculated after request
$this->budgetChecker->checkBudget($user, 0.0);
}
return $next($request);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Services\RateLimit\RateLimitChecker;
use Symfony\Component\HttpFoundation\Response;
class CheckRateLimit
{
public function __construct(
private RateLimitChecker $rateLimitChecker
) {}
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user) {
// Check rate limit before processing request
$this->rateLimitChecker->checkRateLimit($user);
// Increment counter after successful check
$this->rateLimitChecker->incrementCounter($user);
}
return $next($request);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use App\Services\LLM\ProviderFactory;
class ChatCompletionRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Authorization handled by auth middleware
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'provider' => ['required', 'string', function ($attribute, $value, $fail) {
if (!ProviderFactory::isSupported($value)) {
$fail("The {$attribute} must be one of: " . implode(', ', ProviderFactory::getSupportedProviders()));
}
}],
'model' => 'required|string|max:100',
'messages' => 'required|array|min:1',
'messages.*.role' => 'required|string|in:system,user,assistant',
'messages.*.content' => 'required|string',
// Optional parameters
'temperature' => 'sometimes|numeric|min:0|max:2',
'max_tokens' => 'sometimes|integer|min:1|max:100000',
'top_p' => 'sometimes|numeric|min:0|max:1',
'frequency_penalty' => 'sometimes|numeric|min:-2|max:2',
'presence_penalty' => 'sometimes|numeric|min:-2|max:2',
'stop' => 'sometimes|array',
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'provider.required' => 'Provider is required (e.g., openai, anthropic)',
'model.required' => 'Model is required (e.g., gpt-4o-mini, claude-sonnet-4)',
'messages.required' => 'Messages array is required',
'messages.*.role.in' => 'Message role must be system, user, or assistant',
'temperature.between' => 'Temperature must be between 0 and 2',
'max_tokens.min' => 'Max tokens must be at least 1',
];
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Jobs;
use App\Models\LlmRequest;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class LogLlmRequest implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 30;
public int $tries = 3;
public int $maxExceptions = 3;
public function __construct(
private int $userId,
private string $provider,
private string $model,
private array $requestPayload,
private ?array $responsePayload,
private int $promptTokens,
private int $completionTokens,
private int $totalTokens,
private ?int $responseTimeMs,
private float $promptCost,
private float $completionCost,
private float $totalCost,
private string $status,
private ?string $errorMessage = null,
private ?int $httpStatus = null,
private ?string $ipAddress = null,
private ?string $userAgent = null,
private ?string $requestId = null
) {}
public function handle(): void
{
try {
LlmRequest::create([
'user_id' => $this->userId,
'provider' => $this->provider,
'model' => $this->model,
'request_payload' => $this->requestPayload,
'response_payload' => $this->responsePayload,
'prompt_tokens' => $this->promptTokens,
'completion_tokens' => $this->completionTokens,
'total_tokens' => $this->totalTokens,
'response_time_ms' => $this->responseTimeMs,
'prompt_cost' => $this->promptCost,
'completion_cost' => $this->completionCost,
'total_cost' => $this->totalCost,
'status' => $this->status,
'error_message' => $this->errorMessage,
'http_status' => $this->httpStatus,
'ip_address' => $this->ipAddress,
'user_agent' => $this->userAgent,
'request_id' => $this->requestId,
]);
} catch (\Exception $e) {
Log::error('Failed to log LLM request', [
'error' => $e->getMessage(),
'user_id' => $this->userId,
'provider' => $this->provider,
'model' => $this->model,
'request_id' => $this->requestId,
]);
throw $e;
}
}
public function failed(\Throwable $exception): void
{
Log::critical('LogLlmRequest job failed after all retries', [
'user_id' => $this->userId,
'provider' => $this->provider,
'model' => $this->model,
'request_id' => $this->requestId,
'error' => $exception->getMessage(),
]);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Jobs;
use App\Models\UserBudget;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ResetDailyBudgets implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Execute the job.
*/
public function handle(): void
{
$now = now();
$today = $now->startOfDay();
// Find all budgets that need daily reset
$budgets = UserBudget::where('day_started_at', '<', $today)
->where('is_active', true)
->get();
$resetCount = 0;
foreach ($budgets as $budget) {
$budget->current_day_spending = 0.0;
$budget->day_started_at = $today;
$budget->save();
$resetCount++;
}
Log::info('Daily budgets reset', [
'count' => $resetCount,
'date' => $today->toDateString()
]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Jobs;
use App\Models\UserBudget;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ResetMonthlyBudgets implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Execute the job.
*/
public function handle(): void
{
$now = now();
$thisMonth = $now->startOfMonth();
// Find all budgets that need monthly reset
$budgets = UserBudget::where('month_started_at', '<', $thisMonth)
->where('is_active', true)
->get();
$resetCount = 0;
foreach ($budgets as $budget) {
$budget->current_month_spending = 0.0;
$budget->month_started_at = $thisMonth;
$budget->is_budget_exceeded = false;
$budget->last_alert_sent_at = null;
$budget->save();
$resetCount++;
Log::info('Monthly budget reset for user', [
'user_id' => $budget->user_id,
'previous_spending' => $budget->current_month_spending
]);
}
Log::info('Monthly budgets reset', [
'count' => $resetCount,
'month' => $thisMonth->format('Y-m')
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LlmRequest extends Model
{
protected $fillable = [
'user_id',
'provider',
'model',
'request_payload',
'response_payload',
'prompt_tokens',
'completion_tokens',
'total_tokens',
'response_time_ms',
'prompt_cost',
'completion_cost',
'total_cost',
'status',
'error_message',
'http_status',
'ip_address',
'user_agent',
'request_id',
];
protected $casts = [
'request_payload' => 'array',
'response_payload' => 'array',
'prompt_tokens' => 'integer',
'completion_tokens' => 'integer',
'total_tokens' => 'integer',
'response_time_ms' => 'integer',
'prompt_cost' => 'decimal:6',
'completion_cost' => 'decimal:6',
'total_cost' => 'decimal:6',
'http_status' => 'integer',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isSuccess(): bool
{
return $this->status === 'success';
}
public function isFailed(): bool
{
return $this->status === 'failed';
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RateLimit extends Model
{
protected $fillable = [
'user_id',
'requests_per_minute',
'requests_per_hour',
'requests_per_day',
'current_minute_count',
'current_hour_count',
'current_day_count',
'minute_started_at',
'hour_started_at',
'day_started_at',
'is_rate_limited',
'rate_limit_expires_at',
];
protected $casts = [
'requests_per_minute' => 'integer',
'requests_per_hour' => 'integer',
'requests_per_day' => 'integer',
'current_minute_count' => 'integer',
'current_hour_count' => 'integer',
'current_day_count' => 'integer',
'minute_started_at' => 'datetime',
'hour_started_at' => 'datetime',
'day_started_at' => 'datetime',
'is_rate_limited' => 'boolean',
'rate_limit_expires_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isMinuteLimitExceeded(): bool
{
if ($this->minute_started_at->lt(now()->subMinute())) {
return false; // Period expired, should be reset
}
return $this->current_minute_count >= $this->requests_per_minute;
}
public function isHourLimitExceeded(): bool
{
if ($this->hour_started_at->lt(now()->subHour())) {
return false; // Period expired, should be reset
}
return $this->current_hour_count >= $this->requests_per_hour;
}
public function isDayLimitExceeded(): bool
{
if ($this->day_started_at->lt(now()->subDay())) {
return false; // Period expired, should be reset
}
return $this->current_day_count >= $this->requests_per_day;
}
public function isAnyLimitExceeded(): bool
{
return $this->isMinuteLimitExceeded()
|| $this->isHourLimitExceeded()
|| $this->isDayLimitExceeded();
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserBudget extends Model
{
protected $fillable = [
'user_id',
'monthly_limit',
'daily_limit',
'current_month_spending',
'current_day_spending',
'month_started_at',
'day_started_at',
'alert_threshold_percentage',
'last_alert_sent_at',
'is_budget_exceeded',
'is_active',
];
protected $casts = [
'monthly_limit' => 'decimal:2',
'daily_limit' => 'decimal:2',
'current_month_spending' => 'decimal:2',
'current_day_spending' => 'decimal:2',
'month_started_at' => 'date',
'day_started_at' => 'date',
'alert_threshold_percentage' => 'integer',
'last_alert_sent_at' => 'datetime',
'is_budget_exceeded' => 'boolean',
'is_active' => 'boolean',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function getRemainingMonthlyBudget(): float
{
return max(0, $this->monthly_limit - $this->current_month_spending);
}
public function getRemainingDailyBudget(): ?float
{
if (!$this->daily_limit) {
return null;
}
return max(0, $this->daily_limit - $this->current_day_spending);
}
public function getMonthlyUsagePercentage(): float
{
if ($this->monthly_limit == 0) {
return 0;
}
return ($this->current_month_spending / $this->monthly_limit) * 100;
}
public function shouldSendAlert(): bool
{
$percentage = $this->getMonthlyUsagePercentage();
return $percentage >= $this->alert_threshold_percentage
&& (!$this->last_alert_sent_at || $this->last_alert_sent_at->lt(now()->subHours(24)));
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Crypt;
class UserProviderCredential extends Model
{
protected $fillable = [
'user_id',
'provider',
'api_key',
'organization_id',
'is_active',
'last_used_at',
];
protected $hidden = [
'api_key',
];
protected $casts = [
'is_active' => 'boolean',
'last_used_at' => 'datetime',
];
// Automatic encryption when setting
public function setApiKeyAttribute($value): void
{
$this->attributes['api_key'] = Crypt::encryptString($value);
}
// Automatic decryption when getting
public function getApiKeyAttribute($value): string
{
return Crypt::decryptString($value);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function markAsUsed(): void
{
$this->update(['last_used_at' => now()]);
}
}

View File

@@ -0,0 +1,213 @@
<?php
namespace App\Services\Budget;
use App\Models\User;
use App\Models\UserBudget;
use App\Exceptions\InsufficientBudgetException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class BudgetChecker
{
/**
* Check if user has sufficient budget for a request
*
* @param User $user
* @param float $estimatedCost
* @return bool
* @throws InsufficientBudgetException
*/
public function checkBudget(User $user, float $estimatedCost = 0.0): bool
{
$budget = $this->getOrCreateBudget($user);
// If budget is already exceeded, deny immediately
if ($budget->is_budget_exceeded) {
throw new InsufficientBudgetException(
"Budget limit exceeded. Current spending: $" . number_format($budget->current_month_spending, 2) .
" / Monthly limit: $" . number_format($budget->monthly_limit, 2)
);
}
// Check daily limit if set
if ($budget->daily_limit > 0) {
$projectedDailySpending = $budget->current_day_spending + $estimatedCost;
if ($projectedDailySpending > $budget->daily_limit) {
throw new InsufficientBudgetException(
"Daily budget limit would be exceeded. Current: $" . number_format($budget->current_day_spending, 2) .
" / Daily limit: $" . number_format($budget->daily_limit, 2)
);
}
}
// Check monthly limit
if ($budget->monthly_limit > 0) {
$projectedMonthlySpending = $budget->current_month_spending + $estimatedCost;
if ($projectedMonthlySpending > $budget->monthly_limit) {
throw new InsufficientBudgetException(
"Monthly budget limit would be exceeded. Current: $" . number_format($budget->current_month_spending, 2) .
" / Monthly limit: $" . number_format($budget->monthly_limit, 2)
);
}
// Check alert threshold
$usagePercentage = ($projectedMonthlySpending / $budget->monthly_limit) * 100;
if ($usagePercentage >= $budget->alert_threshold_percentage) {
$this->sendBudgetAlert($user, $budget, $usagePercentage);
}
}
return true;
}
/**
* Update user budget after a request
*
* @param User $user
* @param float $actualCost
* @return void
*/
public function updateBudget(User $user, float $actualCost): void
{
$budget = $this->getOrCreateBudget($user);
// Reset periods if needed
$this->checkAndResetPeriods($budget);
// Update spending
$budget->current_month_spending += $actualCost;
$budget->current_day_spending += $actualCost;
// Check if budget is now exceeded
if ($budget->monthly_limit > 0 && $budget->current_month_spending >= $budget->monthly_limit) {
$budget->is_budget_exceeded = true;
}
$budget->save();
// Invalidate cache
Cache::forget("user_budget:{$user->id}");
Log::info('Budget updated', [
'user_id' => $user->id,
'cost' => $actualCost,
'monthly_spending' => $budget->current_month_spending,
'daily_spending' => $budget->current_day_spending
]);
}
/**
* Get or create user budget
*
* @param User $user
* @return UserBudget
*/
private function getOrCreateBudget(User $user): UserBudget
{
$budget = $user->budget;
if (!$budget) {
$budget = UserBudget::create([
'user_id' => $user->id,
'monthly_limit' => config('llm.default_monthly_budget', 100.00),
'daily_limit' => config('llm.default_daily_budget', 10.00),
'month_started_at' => now()->startOfMonth(),
'day_started_at' => now()->startOfDay(),
'alert_threshold_percentage' => 80,
]);
Log::info('Budget created for user', ['user_id' => $user->id]);
}
return $budget;
}
/**
* Check and reset budget periods if needed
*
* @param UserBudget $budget
* @return void
*/
private function checkAndResetPeriods(UserBudget $budget): void
{
$now = now();
// Reset monthly budget if new month
if ($now->startOfMonth()->greaterThan($budget->month_started_at)) {
$budget->current_month_spending = 0.0;
$budget->month_started_at = $now->startOfMonth();
$budget->is_budget_exceeded = false;
$budget->last_alert_sent_at = null;
Log::info('Monthly budget reset', ['user_id' => $budget->user_id]);
}
// Reset daily budget if new day
if ($now->startOfDay()->greaterThan($budget->day_started_at)) {
$budget->current_day_spending = 0.0;
$budget->day_started_at = $now->startOfDay();
Log::info('Daily budget reset', ['user_id' => $budget->user_id]);
}
}
/**
* Send budget alert to user
*
* @param User $user
* @param UserBudget $budget
* @param float $usagePercentage
* @return void
*/
private function sendBudgetAlert(User $user, UserBudget $budget, float $usagePercentage): void
{
// Only send alert once per day
if ($budget->last_alert_sent_at && $budget->last_alert_sent_at->isToday()) {
return;
}
Log::warning('Budget threshold reached', [
'user_id' => $user->id,
'user_email' => $user->email,
'usage_percentage' => round($usagePercentage, 2),
'current_spending' => $budget->current_month_spending,
'monthly_limit' => $budget->monthly_limit
]);
// TODO: Send email notification
// Mail::to($user->email)->send(new BudgetAlertMail($budget, $usagePercentage));
$budget->last_alert_sent_at = now();
$budget->save();
}
/**
* Get budget status for user
*
* @param User $user
* @return array
*/
public function getBudgetStatus(User $user): array
{
$budget = $this->getOrCreateBudget($user);
return [
'monthly_limit' => $budget->monthly_limit,
'daily_limit' => $budget->daily_limit,
'current_month_spending' => $budget->current_month_spending,
'current_day_spending' => $budget->current_day_spending,
'monthly_remaining' => max(0, $budget->monthly_limit - $budget->current_month_spending),
'daily_remaining' => max(0, $budget->daily_limit - $budget->current_day_spending),
'monthly_usage_percentage' => $budget->monthly_limit > 0
? ($budget->current_month_spending / $budget->monthly_limit) * 100
: 0,
'is_exceeded' => $budget->is_budget_exceeded,
'month_started_at' => $budget->month_started_at,
'day_started_at' => $budget->day_started_at,
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Services\LLM\Contracts;
interface ProviderInterface
{
/**
* Send a chat completion request to the provider
*
* @param array $messages Array of message objects with 'role' and 'content'
* @param array $options Additional options (model, temperature, max_tokens, etc.)
* @return array Raw provider response
* @throws \App\Exceptions\ProviderException
*/
public function chatCompletion(array $messages, array $options = []): array;
/**
* Normalize provider response to common format
*
* @param array $response Raw provider response
* @return array Normalized response with: id, model, content, usage, finish_reason
*/
public function normalizeResponse(array $response): array;
/**
* Calculate cost for given token usage
*
* @param int $promptTokens Number of prompt tokens
* @param int $completionTokens Number of completion tokens
* @param string $model Model name
* @return float Total cost in USD
*/
public function calculateCost(int $promptTokens, int $completionTokens, string $model): float;
/**
* Get supported models for this provider
*
* @return array List of supported model names
*/
public function getSupportedModels(): array;
/**
* Validate API key
*
* @return bool True if API key is valid
*/
public function validateApiKey(): bool;
}

View File

@@ -0,0 +1,128 @@
<?php
namespace App\Services\LLM;
use App\Models\ModelPricing;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class CostCalculator
{
/**
* Calculate cost for a specific provider and model
*
* @param string $provider Provider name (openai, anthropic, etc.)
* @param string $model Model name
* @param int $promptTokens Number of prompt tokens
* @param int $completionTokens Number of completion tokens
* @return array ['prompt_cost', 'completion_cost', 'total_cost']
*/
public function calculate(
string $provider,
string $model,
int $promptTokens,
int $completionTokens
): array {
$pricing = $this->getPricing($provider, $model);
if (!$pricing) {
Log::warning("No pricing found for {$provider}/{$model}, returning zero cost");
return [
'prompt_cost' => 0.0,
'completion_cost' => 0.0,
'total_cost' => 0.0,
];
}
$promptCost = ($promptTokens / 1_000_000) * $pricing->input_price_per_million;
$completionCost = ($completionTokens / 1_000_000) * $pricing->output_price_per_million;
$totalCost = $promptCost + $completionCost;
return [
'prompt_cost' => round($promptCost, 6),
'completion_cost' => round($completionCost, 6),
'total_cost' => round($totalCost, 6),
];
}
/**
* Estimate cost before making the request
* Uses average token estimation
*
* @param string $provider
* @param string $model
* @param int $estimatedPromptTokens
* @param int $estimatedCompletionTokens
* @return float Estimated total cost
*/
public function estimateCost(
string $provider,
string $model,
int $estimatedPromptTokens,
int $estimatedCompletionTokens
): float {
$costs = $this->calculate($provider, $model, $estimatedPromptTokens, $estimatedCompletionTokens);
return $costs['total_cost'];
}
/**
* Get pricing from cache or database
*
* @param string $provider
* @param string $model
* @return ModelPricing|null
*/
private function getPricing(string $provider, string $model): ?ModelPricing
{
$cacheKey = "pricing:{$provider}:{$model}";
$cacheTTL = 3600; // 1 hour
return Cache::remember($cacheKey, $cacheTTL, function () use ($provider, $model) {
return ModelPricing::where('provider', $provider)
->where('model', $model)
->where('is_active', true)
->where('effective_from', '<=', now())
->where(function ($query) {
$query->whereNull('effective_until')
->orWhere('effective_until', '>=', now());
})
->orderBy('effective_from', 'desc')
->first();
});
}
/**
* Clear pricing cache for a specific provider/model
*
* @param string|null $provider
* @param string|null $model
* @return void
*/
public function clearCache(?string $provider = null, ?string $model = null): void
{
if ($provider && $model) {
Cache::forget("pricing:{$provider}:{$model}");
} else {
// Clear all pricing cache
Cache::flush();
}
}
/**
* Get all active pricing entries
*
* @return \Illuminate\Support\Collection
*/
public function getAllActivePricing(): \Illuminate\Support\Collection
{
return ModelPricing::where('is_active', true)
->where('effective_from', '<=', now())
->where(function ($query) {
$query->whereNull('effective_until')
->orWhere('effective_until', '>=', now());
})
->orderBy('provider')
->orderBy('model')
->get();
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace App\Services\LLM;
use App\Models\User;
use App\Models\UserProviderCredential;
use App\Exceptions\{ProviderException, InsufficientBudgetException, RateLimitExceededException};
use Illuminate\Support\Facades\Log;
class GatewayService
{
public function __construct(
private CostCalculator $costCalculator,
private RequestLogger $requestLogger,
) {}
/**
* Process a chat completion request through the gateway
*
* @param User $user
* @param string $provider
* @param string $model
* @param array $messages
* @param array $options
* @param string|null $ipAddress
* @param string|null $userAgent
* @return array
* @throws ProviderException
* @throws InsufficientBudgetException
*/
public function chatCompletion(
User $user,
string $provider,
string $model,
array $messages,
array $options = [],
?string $ipAddress = null,
?string $userAgent = null
): array {
$startTime = microtime(true);
// 1. Get user's API credentials
$credential = $this->getUserCredential($user, $provider);
// 2. Create provider instance
$providerInstance = ProviderFactory::create($provider, $credential->api_key);
// 3. Build request payload
$requestPayload = [
'provider' => $provider,
'model' => $model,
'messages' => $messages,
'options' => $options,
];
try {
// 4. Make the API request
$response = $providerInstance->chatCompletion($messages, array_merge($options, ['model' => $model]));
// 5. Normalize response
$normalized = $providerInstance->normalizeResponse($response);
// 6. Calculate response time
$responseTimeMs = (int) round((microtime(true) - $startTime) * 1000);
// 7. Calculate costs
$costs = $this->costCalculator->calculate(
$provider,
$normalized['model'],
$normalized['usage']['prompt_tokens'],
$normalized['usage']['completion_tokens']
);
// 8. Log request asynchronously
$requestId = $this->requestLogger->logSuccess(
$user->id,
$provider,
$normalized['model'],
$requestPayload,
$normalized,
$costs,
$responseTimeMs,
$ipAddress,
$userAgent
);
// 9. Update user budget (synchronously for accuracy)
$this->updateUserBudget($user, $costs['total_cost']);
// 10. Return response with metadata
return [
'success' => true,
'request_id' => $requestId,
'provider' => $provider,
'model' => $normalized['model'],
'content' => $normalized['content'],
'role' => $normalized['role'],
'finish_reason' => $normalized['finish_reason'],
'usage' => $normalized['usage'],
'cost' => $costs,
'response_time_ms' => $responseTimeMs,
];
} catch (ProviderException $e) {
// Log failure
$this->requestLogger->logFailure(
$user->id,
$provider,
$model,
$requestPayload,
$e->getMessage(),
$e->getCode(),
$ipAddress,
$userAgent
);
throw $e;
}
}
/**
* Get user's credential for a provider
*/
private function getUserCredential(User $user, string $provider): UserProviderCredential
{
$credential = UserProviderCredential::where('user_id', $user->id)
->where('provider', $provider)
->where('is_active', true)
->first();
if (!$credential) {
throw new ProviderException(
"No active API credentials found for provider: {$provider}",
400
);
}
// Update last used timestamp
$credential->update(['last_used_at' => now()]);
return $credential;
}
/**
* Update user's budget with spending
*/
private function updateUserBudget(User $user, float $cost): void
{
$budget = $user->budget;
if (!$budget) {
return; // No budget configured
}
$budget->increment('current_month_spending', $cost);
$budget->increment('current_day_spending', $cost);
// Check if budget exceeded
if ($budget->current_month_spending >= $budget->monthly_limit) {
$budget->update(['is_budget_exceeded' => true]);
}
// Check alert threshold
if ($budget->alert_threshold_percentage) {
$threshold = $budget->monthly_limit * ($budget->alert_threshold_percentage / 100);
if ($budget->current_month_spending >= $threshold && !$budget->last_alert_sent_at) {
// TODO: Dispatch alert notification
$budget->update(['last_alert_sent_at' => now()]);
}
}
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Services\LLM;
use App\Services\LLM\Contracts\ProviderInterface;
use App\Services\LLM\Providers\{
OpenAIProvider,
AnthropicProvider,
MistralProvider,
GeminiProvider,
DeepSeekProvider
};
class ProviderFactory
{
/**
* Create a provider instance
*
* @param string $provider Provider name (openai, anthropic, mistral, gemini, deepseek)
* @param string $apiKey API key for the provider
* @return ProviderInterface
* @throws \InvalidArgumentException
*/
public static function create(string $provider, string $apiKey): ProviderInterface
{
return match (strtolower($provider)) {
'openai' => new OpenAIProvider($apiKey),
'anthropic' => new AnthropicProvider($apiKey),
'mistral' => new MistralProvider($apiKey),
'gemini' => new GeminiProvider($apiKey),
'deepseek' => new DeepSeekProvider($apiKey),
default => throw new \InvalidArgumentException("Unknown provider: {$provider}")
};
}
/**
* Get list of supported providers
*
* @return array
*/
public static function getSupportedProviders(): array
{
return [
'openai',
'anthropic',
'mistral',
'gemini',
'deepseek',
];
}
/**
* Check if a provider is supported
*
* @param string $provider
* @return bool
*/
public static function isSupported(string $provider): bool
{
return in_array(strtolower($provider), self::getSupportedProviders());
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Services\LLM\Providers;
use App\Services\LLM\Contracts\ProviderInterface;
use App\Exceptions\ProviderException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
abstract class AbstractProvider implements ProviderInterface
{
protected string $apiKey;
protected string $baseUrl;
protected int $timeout = 60;
protected int $retryAttempts = 3;
protected int $retryDelay = 1000; // milliseconds
public function __construct(string $apiKey)
{
$this->apiKey = $apiKey;
}
/**
* Build request payload for provider
*/
abstract protected function buildRequest(array $messages, array $options): array;
/**
* Get authorization headers for provider
*/
abstract protected function getAuthHeaders(): array;
/**
* Make HTTP request with retry logic
*/
protected function makeRequest(string $endpoint, array $data): array
{
$attempt = 0;
$lastException = null;
while ($attempt < $this->retryAttempts) {
try {
$response = Http::withHeaders($this->getAuthHeaders())
->timeout($this->timeout)
->post($this->baseUrl . $endpoint, $data);
if ($response->successful()) {
return $response->json();
}
// Handle specific HTTP errors
if ($response->status() === 401) {
throw new ProviderException('Invalid API key', 401);
}
if ($response->status() === 429) {
throw new ProviderException('Rate limit exceeded', 429);
}
if ($response->status() >= 500) {
throw new ProviderException('Provider server error', $response->status());
}
throw new ProviderException(
'Request failed: ' . $response->body(),
$response->status()
);
} catch (\Exception $e) {
$lastException = $e;
$attempt++;
if ($attempt < $this->retryAttempts) {
Log::warning("Provider request failed, retrying ({$attempt}/{$this->retryAttempts})", [
'provider' => static::class,
'error' => $e->getMessage()
]);
usleep($this->retryDelay * 1000);
}
}
}
throw new ProviderException(
'All retry attempts failed: ' . ($lastException ? $lastException->getMessage() : 'Unknown error'),
$lastException ? $lastException->getCode() : 500
);
}
/**
* Validate API key by making a test request
*/
public function validateApiKey(): bool
{
try {
$this->chatCompletion([
['role' => 'user', 'content' => 'test']
], ['max_tokens' => 5]);
return true;
} catch (\Exception $e) {
return false;
}
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Services\LLM\Providers;
use App\Models\ModelPricing;
use Illuminate\Support\Facades\Cache;
class AnthropicProvider extends AbstractProvider
{
protected string $baseUrl = 'https://api.anthropic.com/v1';
private string $apiVersion = '2023-06-01';
protected function buildRequest(array $messages, array $options): array
{
// Anthropic requires system message separate
$systemMessage = null;
$formattedMessages = [];
foreach ($messages as $message) {
if ($message['role'] === 'system') {
$systemMessage = $message['content'];
} else {
$formattedMessages[] = $message;
}
}
$request = array_filter([
'model' => $options['model'] ?? 'claude-sonnet-4',
'max_tokens' => $options['max_tokens'] ?? 4096,
'messages' => $formattedMessages,
'system' => $systemMessage,
'temperature' => $options['temperature'] ?? null,
'top_p' => $options['top_p'] ?? null,
'stop_sequences' => $options['stop'] ?? null,
], fn($value) => $value !== null);
return $request;
}
protected function getAuthHeaders(): array
{
return [
'x-api-key' => $this->apiKey,
'anthropic-version' => $this->apiVersion,
'Content-Type' => 'application/json',
];
}
public function chatCompletion(array $messages, array $options = []): array
{
$data = $this->buildRequest($messages, $options);
return $this->makeRequest('/messages', $data);
}
public function normalizeResponse(array $response): array
{
$content = '';
if (isset($response['content']) && is_array($response['content'])) {
foreach ($response['content'] as $block) {
if ($block['type'] === 'text') {
$content .= $block['text'];
}
}
}
return [
'id' => $response['id'] ?? null,
'model' => $response['model'] ?? null,
'content' => $content,
'role' => $response['role'] ?? 'assistant',
'finish_reason' => $response['stop_reason'] ?? null,
'usage' => [
'prompt_tokens' => $response['usage']['input_tokens'] ?? 0,
'completion_tokens' => $response['usage']['output_tokens'] ?? 0,
'total_tokens' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
],
'raw_response' => $response,
];
}
public function calculateCost(int $promptTokens, int $completionTokens, string $model): float
{
$cacheKey = "pricing:anthropic:{$model}";
$pricing = Cache::remember($cacheKey, 3600, function () use ($model) {
return ModelPricing::where('provider', 'anthropic')
->where('model', $model)
->where('is_active', true)
->first();
});
if (!$pricing) {
return 0.0;
}
$promptCost = ($promptTokens / 1_000_000) * $pricing->input_price_per_million;
$completionCost = ($completionTokens / 1_000_000) * $pricing->output_price_per_million;
return round($promptCost + $completionCost, 6);
}
public function getSupportedModels(): array
{
return [
'claude-opus-4',
'claude-sonnet-4',
'claude-haiku-4',
'claude-3-opus',
'claude-3-sonnet',
'claude-3-haiku',
];
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Services\LLM\Providers;
use App\Models\ModelPricing;
use Illuminate\Support\Facades\Cache;
class DeepSeekProvider extends AbstractProvider
{
protected string $baseUrl = 'https://api.deepseek.com/v1';
protected function buildRequest(array $messages, array $options): array
{
return array_filter([
'model' => $options['model'] ?? 'deepseek-chat',
'messages' => $messages,
'temperature' => $options['temperature'] ?? 0.7,
'max_tokens' => $options['max_tokens'] ?? null,
'top_p' => $options['top_p'] ?? null,
'frequency_penalty' => $options['frequency_penalty'] ?? null,
'presence_penalty' => $options['presence_penalty'] ?? null,
'stop' => $options['stop'] ?? null,
'stream' => false,
], fn($value) => $value !== null);
}
protected function getAuthHeaders(): array
{
return [
'Authorization' => 'Bearer ' . $this->apiKey,
'Content-Type' => 'application/json',
];
}
public function chatCompletion(array $messages, array $options = []): array
{
$data = $this->buildRequest($messages, $options);
return $this->makeRequest('/chat/completions', $data);
}
public function normalizeResponse(array $response): array
{
return [
'id' => $response['id'] ?? null,
'model' => $response['model'] ?? null,
'content' => $response['choices'][0]['message']['content'] ?? '',
'role' => $response['choices'][0]['message']['role'] ?? 'assistant',
'finish_reason' => $response['choices'][0]['finish_reason'] ?? null,
'usage' => [
'prompt_tokens' => $response['usage']['prompt_tokens'] ?? 0,
'completion_tokens' => $response['usage']['completion_tokens'] ?? 0,
'total_tokens' => $response['usage']['total_tokens'] ?? 0,
],
'raw_response' => $response,
];
}
public function calculateCost(int $promptTokens, int $completionTokens, string $model): float
{
$cacheKey = "pricing:deepseek:{$model}";
$pricing = Cache::remember($cacheKey, 3600, function () use ($model) {
return ModelPricing::where('provider', 'deepseek')
->where('model', $model)
->where('is_active', true)
->first();
});
if (!$pricing) {
return 0.0;
}
$promptCost = ($promptTokens / 1_000_000) * $pricing->input_price_per_million;
$completionCost = ($completionTokens / 1_000_000) * $pricing->output_price_per_million;
return round($promptCost + $completionCost, 6);
}
public function getSupportedModels(): array
{
return [
'deepseek-chat',
'deepseek-coder',
'deepseek-reasoner',
];
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Services\LLM\Providers;
use App\Models\ModelPricing;
use Illuminate\Support\Facades\Cache;
class GeminiProvider extends AbstractProvider
{
protected string $baseUrl = 'https://generativelanguage.googleapis.com/v1beta';
protected function buildRequest(array $messages, array $options): array
{
// Gemini uses a different message format
$contents = [];
foreach ($messages as $message) {
$role = $message['role'];
// Gemini uses 'model' instead of 'assistant' and doesn't support 'system'
if ($role === 'assistant') {
$role = 'model';
} elseif ($role === 'system') {
// Convert system messages to user messages with context
$role = 'user';
}
$contents[] = [
'role' => $role,
'parts' => [
['text' => $message['content']]
]
];
}
$request = [
'contents' => $contents,
];
// Add generation config if options provided
$generationConfig = array_filter([
'temperature' => $options['temperature'] ?? null,
'maxOutputTokens' => $options['max_tokens'] ?? null,
'topP' => $options['top_p'] ?? null,
'stopSequences' => $options['stop'] ?? null,
], fn($value) => $value !== null);
if (!empty($generationConfig)) {
$request['generationConfig'] = $generationConfig;
}
return $request;
}
protected function getAuthHeaders(): array
{
return [
'Content-Type' => 'application/json',
];
}
public function chatCompletion(array $messages, array $options = []): array
{
$model = $options['model'] ?? 'gemini-pro';
$data = $this->buildRequest($messages, $options);
// Gemini uses API key as query parameter
$endpoint = "/models/{$model}:generateContent?key={$this->apiKey}";
return $this->makeRequest($endpoint, $data);
}
public function normalizeResponse(array $response): array
{
$candidate = $response['candidates'][0] ?? [];
$content = $candidate['content'] ?? [];
$parts = $content['parts'] ?? [];
$textContent = '';
foreach ($parts as $part) {
$textContent .= $part['text'] ?? '';
}
$usageMetadata = $response['usageMetadata'] ?? [];
return [
'id' => null, // Gemini doesn't provide an ID
'model' => $response['modelVersion'] ?? null,
'content' => $textContent,
'role' => 'assistant',
'finish_reason' => $candidate['finishReason'] ?? null,
'usage' => [
'prompt_tokens' => $usageMetadata['promptTokenCount'] ?? 0,
'completion_tokens' => $usageMetadata['candidatesTokenCount'] ?? 0,
'total_tokens' => $usageMetadata['totalTokenCount'] ?? 0,
],
'raw_response' => $response,
];
}
public function calculateCost(int $promptTokens, int $completionTokens, string $model): float
{
$cacheKey = "pricing:gemini:{$model}";
$pricing = Cache::remember($cacheKey, 3600, function () use ($model) {
return ModelPricing::where('provider', 'gemini')
->where('model', $model)
->where('is_active', true)
->first();
});
if (!$pricing) {
return 0.0;
}
$promptCost = ($promptTokens / 1_000_000) * $pricing->input_price_per_million;
$completionCost = ($completionTokens / 1_000_000) * $pricing->output_price_per_million;
return round($promptCost + $completionCost, 6);
}
public function getSupportedModels(): array
{
return [
'gemini-pro',
'gemini-pro-vision',
'gemini-1.5-pro',
'gemini-1.5-flash',
'gemini-ultra',
];
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Services\LLM\Providers;
use App\Models\ModelPricing;
use Illuminate\Support\Facades\Cache;
class MistralProvider extends AbstractProvider
{
protected string $baseUrl = 'https://api.mistral.ai/v1';
protected function buildRequest(array $messages, array $options): array
{
return array_filter([
'model' => $options['model'] ?? 'mistral-small-latest',
'messages' => $messages,
'temperature' => $options['temperature'] ?? 0.7,
'max_tokens' => $options['max_tokens'] ?? null,
'top_p' => $options['top_p'] ?? null,
'stream' => false,
'safe_prompt' => $options['safe_prompt'] ?? false,
'random_seed' => $options['random_seed'] ?? null,
], fn($value) => $value !== null && $value !== false);
}
protected function getAuthHeaders(): array
{
return [
'Authorization' => 'Bearer ' . $this->apiKey,
'Content-Type' => 'application/json',
];
}
public function chatCompletion(array $messages, array $options = []): array
{
$data = $this->buildRequest($messages, $options);
return $this->makeRequest('/chat/completions', $data);
}
public function normalizeResponse(array $response): array
{
return [
'id' => $response['id'] ?? null,
'model' => $response['model'] ?? null,
'content' => $response['choices'][0]['message']['content'] ?? '',
'role' => $response['choices'][0]['message']['role'] ?? 'assistant',
'finish_reason' => $response['choices'][0]['finish_reason'] ?? null,
'usage' => [
'prompt_tokens' => $response['usage']['prompt_tokens'] ?? 0,
'completion_tokens' => $response['usage']['completion_tokens'] ?? 0,
'total_tokens' => $response['usage']['total_tokens'] ?? 0,
],
'raw_response' => $response,
];
}
public function calculateCost(int $promptTokens, int $completionTokens, string $model): float
{
$cacheKey = "pricing:mistral:{$model}";
$pricing = Cache::remember($cacheKey, 3600, function () use ($model) {
return ModelPricing::where('provider', 'mistral')
->where('model', $model)
->where('is_active', true)
->first();
});
if (!$pricing) {
return 0.0;
}
$promptCost = ($promptTokens / 1_000_000) * $pricing->input_price_per_million;
$completionCost = ($completionTokens / 1_000_000) * $pricing->output_price_per_million;
return round($promptCost + $completionCost, 6);
}
public function getSupportedModels(): array
{
return [
'mistral-large-latest',
'mistral-medium-latest',
'mistral-small-latest',
'mistral-tiny',
'open-mistral-7b',
'open-mixtral-8x7b',
'open-mixtral-8x22b',
];
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Services\LLM\Providers;
use App\Models\ModelPricing;
use Illuminate\Support\Facades\Cache;
class OpenAIProvider extends AbstractProvider
{
protected string $baseUrl = 'https://api.openai.com/v1';
protected function buildRequest(array $messages, array $options): array
{
return array_filter([
'model' => $options['model'] ?? 'gpt-4o-mini',
'messages' => $messages,
'temperature' => $options['temperature'] ?? 0.7,
'max_tokens' => $options['max_tokens'] ?? null,
'top_p' => $options['top_p'] ?? null,
'frequency_penalty' => $options['frequency_penalty'] ?? null,
'presence_penalty' => $options['presence_penalty'] ?? null,
'stop' => $options['stop'] ?? null,
'stream' => false,
], fn($value) => $value !== null);
}
protected function getAuthHeaders(): array
{
return [
'Authorization' => 'Bearer ' . $this->apiKey,
'Content-Type' => 'application/json',
];
}
public function chatCompletion(array $messages, array $options = []): array
{
$data = $this->buildRequest($messages, $options);
return $this->makeRequest('/chat/completions', $data);
}
public function normalizeResponse(array $response): array
{
return [
'id' => $response['id'] ?? null,
'model' => $response['model'] ?? null,
'content' => $response['choices'][0]['message']['content'] ?? '',
'role' => $response['choices'][0]['message']['role'] ?? 'assistant',
'finish_reason' => $response['choices'][0]['finish_reason'] ?? null,
'usage' => [
'prompt_tokens' => $response['usage']['prompt_tokens'] ?? 0,
'completion_tokens' => $response['usage']['completion_tokens'] ?? 0,
'total_tokens' => $response['usage']['total_tokens'] ?? 0,
],
'raw_response' => $response,
];
}
public function calculateCost(int $promptTokens, int $completionTokens, string $model): float
{
$cacheKey = "pricing:openai:{$model}";
$pricing = Cache::remember($cacheKey, 3600, function () use ($model) {
return ModelPricing::where('provider', 'openai')
->where('model', $model)
->where('is_active', true)
->first();
});
if (!$pricing) {
return 0.0;
}
$promptCost = ($promptTokens / 1_000_000) * $pricing->input_price_per_million;
$completionCost = ($completionTokens / 1_000_000) * $pricing->output_price_per_million;
return round($promptCost + $completionCost, 6);
}
public function getSupportedModels(): array
{
return [
'gpt-4o',
'gpt-4o-mini',
'gpt-4-turbo',
'gpt-4',
'gpt-3.5-turbo',
];
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Services\LLM;
use App\Jobs\LogLlmRequest;
use Illuminate\Support\Str;
class RequestLogger
{
/**
* Log a successful LLM request
*/
public function logSuccess(
int $userId,
string $provider,
string $model,
array $requestPayload,
array $responsePayload,
array $costs,
int $responseTimeMs,
?string $ipAddress = null,
?string $userAgent = null
): string {
$requestId = $this->generateRequestId();
LogLlmRequest::dispatch(
userId: $userId,
provider: $provider,
model: $model,
requestPayload: $requestPayload,
responsePayload: $responsePayload,
promptTokens: $responsePayload['usage']['prompt_tokens'] ?? 0,
completionTokens: $responsePayload['usage']['completion_tokens'] ?? 0,
totalTokens: $responsePayload['usage']['total_tokens'] ?? 0,
responseTimeMs: $responseTimeMs,
promptCost: $costs['prompt_cost'],
completionCost: $costs['completion_cost'],
totalCost: $costs['total_cost'],
status: 'success',
errorMessage: null,
httpStatus: 200,
ipAddress: $ipAddress,
userAgent: $userAgent,
requestId: $requestId
);
return $requestId;
}
/**
* Log a failed LLM request
*/
public function logFailure(
int $userId,
string $provider,
string $model,
array $requestPayload,
string $errorMessage,
int $httpStatus,
?string $ipAddress = null,
?string $userAgent = null
): string {
$requestId = $this->generateRequestId();
LogLlmRequest::dispatch(
userId: $userId,
provider: $provider,
model: $model,
requestPayload: $requestPayload,
responsePayload: null,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
responseTimeMs: null,
promptCost: 0.0,
completionCost: 0.0,
totalCost: 0.0,
status: 'failed',
errorMessage: $errorMessage,
httpStatus: $httpStatus,
ipAddress: $ipAddress,
userAgent: $userAgent,
requestId: $requestId
);
return $requestId;
}
/**
* Generate unique request ID
*/
private function generateRequestId(): string
{
return 'req_' . Str::random(24);
}
}

View File

@@ -0,0 +1,240 @@
<?php
namespace App\Services\RateLimit;
use App\Models\User;
use App\Models\RateLimit;
use App\Exceptions\RateLimitExceededException;
use Illuminate\Support\Facades\Log;
class RateLimitChecker
{
/**
* Check if user has exceeded rate limits
*
* @param User $user
* @return bool
* @throws RateLimitExceededException
*/
public function checkRateLimit(User $user): bool
{
$rateLimit = $this->getOrCreateRateLimit($user);
// If currently rate limited, check if ban has expired
if ($rateLimit->is_rate_limited) {
if ($rateLimit->rate_limit_expires_at && now()->greaterThan($rateLimit->rate_limit_expires_at)) {
// Rate limit expired, reset
$rateLimit->is_rate_limited = false;
$rateLimit->rate_limit_expires_at = null;
$rateLimit->save();
} else {
// Still rate limited
$expiresIn = $rateLimit->rate_limit_expires_at
? $rateLimit->rate_limit_expires_at->diffInSeconds(now())
: 60;
throw new RateLimitExceededException(
"Rate limit exceeded. Please try again in " . $expiresIn . " seconds."
);
}
}
// Reset counters if periods have passed
$this->resetPeriodsIfNeeded($rateLimit);
// Check minute limit
if ($rateLimit->requests_per_minute > 0) {
if ($rateLimit->current_minute_count >= $rateLimit->requests_per_minute) {
$this->setRateLimited($rateLimit, 60);
throw new RateLimitExceededException(
"Minute rate limit exceeded ({$rateLimit->requests_per_minute} requests/min). Try again in 60 seconds."
);
}
}
// Check hour limit
if ($rateLimit->requests_per_hour > 0) {
if ($rateLimit->current_hour_count >= $rateLimit->requests_per_hour) {
$this->setRateLimited($rateLimit, 3600);
throw new RateLimitExceededException(
"Hourly rate limit exceeded ({$rateLimit->requests_per_hour} requests/hour). Try again in 1 hour."
);
}
}
// Check day limit
if ($rateLimit->requests_per_day > 0) {
if ($rateLimit->current_day_count >= $rateLimit->requests_per_day) {
$secondsUntilMidnight = now()->endOfDay()->diffInSeconds(now());
$this->setRateLimited($rateLimit, $secondsUntilMidnight);
throw new RateLimitExceededException(
"Daily rate limit exceeded ({$rateLimit->requests_per_day} requests/day). Try again tomorrow."
);
}
}
return true;
}
/**
* Increment rate limit counters after a request
*
* @param User $user
* @return void
*/
public function incrementCounter(User $user): void
{
$rateLimit = $this->getOrCreateRateLimit($user);
// Reset periods if needed
$this->resetPeriodsIfNeeded($rateLimit);
// Increment counters
$rateLimit->current_minute_count++;
$rateLimit->current_hour_count++;
$rateLimit->current_day_count++;
$rateLimit->save();
Log::debug('Rate limit counter incremented', [
'user_id' => $user->id,
'minute_count' => $rateLimit->current_minute_count,
'hour_count' => $rateLimit->current_hour_count,
'day_count' => $rateLimit->current_day_count
]);
}
/**
* Get or create rate limit for user
*
* @param User $user
* @return RateLimit
*/
private function getOrCreateRateLimit(User $user): RateLimit
{
$rateLimit = $user->rateLimit;
if (!$rateLimit) {
$rateLimit = RateLimit::create([
'user_id' => $user->id,
'requests_per_minute' => config('llm.rate_limit.requests_per_minute', 60),
'requests_per_hour' => config('llm.rate_limit.requests_per_hour', 1000),
'requests_per_day' => config('llm.rate_limit.requests_per_day', 10000),
'minute_started_at' => now(),
'hour_started_at' => now(),
'day_started_at' => now()->startOfDay(),
]);
Log::info('Rate limit created for user', ['user_id' => $user->id]);
}
return $rateLimit;
}
/**
* Reset rate limit periods if needed
*
* @param RateLimit $rateLimit
* @return void
*/
private function resetPeriodsIfNeeded(RateLimit $rateLimit): void
{
$now = now();
$changed = false;
// Reset minute counter if a minute has passed
if ($now->diffInSeconds($rateLimit->minute_started_at) >= 60) {
$rateLimit->current_minute_count = 0;
$rateLimit->minute_started_at = $now;
$changed = true;
}
// Reset hour counter if an hour has passed
if ($now->diffInSeconds($rateLimit->hour_started_at) >= 3600) {
$rateLimit->current_hour_count = 0;
$rateLimit->hour_started_at = $now;
$changed = true;
}
// Reset day counter if a new day has started
if ($now->startOfDay()->greaterThan($rateLimit->day_started_at)) {
$rateLimit->current_day_count = 0;
$rateLimit->day_started_at = $now->startOfDay();
$changed = true;
}
if ($changed) {
$rateLimit->save();
}
}
/**
* Set user as rate limited
*
* @param RateLimit $rateLimit
* @param int $durationSeconds
* @return void
*/
private function setRateLimited(RateLimit $rateLimit, int $durationSeconds): void
{
$rateLimit->is_rate_limited = true;
$rateLimit->rate_limit_expires_at = now()->addSeconds($durationSeconds);
$rateLimit->save();
Log::warning('User rate limited', [
'user_id' => $rateLimit->user_id,
'expires_at' => $rateLimit->rate_limit_expires_at,
'duration_seconds' => $durationSeconds
]);
}
/**
* Get rate limit status for user
*
* @param User $user
* @return array
*/
public function getRateLimitStatus(User $user): array
{
$rateLimit = $this->getOrCreateRateLimit($user);
return [
'requests_per_minute' => $rateLimit->requests_per_minute,
'requests_per_hour' => $rateLimit->requests_per_hour,
'requests_per_day' => $rateLimit->requests_per_day,
'current_minute_count' => $rateLimit->current_minute_count,
'current_hour_count' => $rateLimit->current_hour_count,
'current_day_count' => $rateLimit->current_day_count,
'minute_remaining' => max(0, $rateLimit->requests_per_minute - $rateLimit->current_minute_count),
'hour_remaining' => max(0, $rateLimit->requests_per_hour - $rateLimit->current_hour_count),
'day_remaining' => max(0, $rateLimit->requests_per_day - $rateLimit->current_day_count),
'is_rate_limited' => $rateLimit->is_rate_limited,
'rate_limit_expires_at' => $rateLimit->rate_limit_expires_at,
];
}
/**
* Manually reset rate limit for user (admin function)
*
* @param User $user
* @return void
*/
public function resetRateLimit(User $user): void
{
$rateLimit = $this->getOrCreateRateLimit($user);
$rateLimit->current_minute_count = 0;
$rateLimit->current_hour_count = 0;
$rateLimit->current_day_count = 0;
$rateLimit->is_rate_limited = false;
$rateLimit->rate_limit_expires_at = null;
$rateLimit->minute_started_at = now();
$rateLimit->hour_started_at = now();
$rateLimit->day_started_at = now()->startOfDay();
$rateLimit->save();
Log::info('Rate limit manually reset', ['user_id' => $user->id]);
}
}

View File

@@ -0,0 +1,90 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Budget Settings
|--------------------------------------------------------------------------
|
| These values are used when creating new user budgets
|
*/
'default_monthly_budget' => env('LLM_DEFAULT_MONTHLY_BUDGET', 100.00),
'default_daily_budget' => env('LLM_DEFAULT_DAILY_BUDGET', 10.00),
/*
|--------------------------------------------------------------------------
| Rate Limiting Settings
|--------------------------------------------------------------------------
|
| Default rate limits for API requests per user
|
*/
'rate_limit' => [
'requests_per_minute' => env('LLM_RATE_LIMIT_PER_MINUTE', 60),
'requests_per_hour' => env('LLM_RATE_LIMIT_PER_HOUR', 1000),
'requests_per_day' => env('LLM_RATE_LIMIT_PER_DAY', 10000),
],
/*
|--------------------------------------------------------------------------
| Supported Providers
|--------------------------------------------------------------------------
|
| List of AI providers supported by the gateway
|
*/
'providers' => [
'openai' => [
'name' => 'OpenAI',
'api_url' => 'https://api.openai.com/v1',
'models' => ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'],
],
'anthropic' => [
'name' => 'Anthropic (Claude)',
'api_url' => 'https://api.anthropic.com/v1',
'models' => ['claude-opus-4', 'claude-sonnet-4', 'claude-haiku-4'],
],
'mistral' => [
'name' => 'Mistral AI',
'api_url' => 'https://api.mistral.ai/v1',
'models' => ['mistral-large', 'mistral-medium', 'mistral-small'],
],
'gemini' => [
'name' => 'Google Gemini',
'api_url' => 'https://generativelanguage.googleapis.com/v1beta',
'models' => ['gemini-pro', 'gemini-pro-vision'],
],
'deepseek' => [
'name' => 'DeepSeek',
'api_url' => 'https://api.deepseek.com/v1',
'models' => ['deepseek-chat', 'deepseek-coder'],
],
],
/*
|--------------------------------------------------------------------------
| Logging Settings
|--------------------------------------------------------------------------
|
| Configuration for request logging
|
*/
'logging' => [
'enabled' => env('LLM_LOGGING_ENABLED', true),
'queue' => env('LLM_LOGGING_QUEUE', true),
],
/*
|--------------------------------------------------------------------------
| Alert Settings
|--------------------------------------------------------------------------
|
| Budget alert threshold and notification settings
|
*/
'alerts' => [
'budget_threshold_percentage' => env('LLM_ALERT_THRESHOLD', 80),
'email_enabled' => env('LLM_ALERT_EMAIL_ENABLED', true),
],
];

View File

@@ -0,0 +1,31 @@
<?php
// Create Admin User Script
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$kernel->bootstrap();
use App\Models\User;
use Illuminate\Support\Facades\Hash;
try {
$user = User::firstOrCreate(
['email' => 'admin@example.com'],
[
'name' => 'Admin User',
'password' => Hash::make('password'),
'email_verified_at' => now(),
]
);
echo "\n✅ User created successfully!\n";
echo "📧 Email: admin@example.com\n";
echo "🔑 Password: password\n";
echo "👤 Name: {$user->name}\n";
echo "🆔 ID: {$user->id}\n\n";
} catch (Exception $e) {
echo "\n❌ Error: " . $e->getMessage() . "\n\n";
}

View File

@@ -0,0 +1,49 @@
<?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('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@@ -0,0 +1,32 @@
<?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::create('user_provider_credentials', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->onDelete('cascade');
$table->string('provider', 50)->comment('openai, anthropic, mistral, gemini, deepseek');
$table->text('api_key')->comment('Encrypted API key');
$table->string('organization_id', 255)->nullable()->comment('Optional organization ID');
$table->boolean('is_active')->default(true);
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
$table->unique(['user_id', 'provider']);
$table->index('user_id');
$table->index('provider');
$table->index('is_active');
});
}
public function down(): void
{
Schema::dropIfExists('user_provider_credentials');
}
};

View File

@@ -0,0 +1,57 @@
<?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::create('llm_requests', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->onDelete('cascade');
$table->string('provider', 50);
$table->string('model', 100);
// Request Details
$table->json('request_payload')->comment('Original request');
$table->json('response_payload')->nullable()->comment('Provider response');
// Tokens & Timing
$table->unsignedInteger('prompt_tokens')->default(0);
$table->unsignedInteger('completion_tokens')->default(0);
$table->unsignedInteger('total_tokens')->default(0);
$table->unsignedInteger('response_time_ms')->nullable()->comment('Response time in milliseconds');
// Cost Calculation
$table->decimal('prompt_cost', 10, 6)->default(0)->comment('Cost in USD');
$table->decimal('completion_cost', 10, 6)->default(0)->comment('Cost in USD');
$table->decimal('total_cost', 10, 6)->default(0)->comment('Cost in USD');
// Status & Error Handling
$table->string('status', 20)->default('pending')->comment('pending, success, failed, rate_limited');
$table->text('error_message')->nullable();
$table->unsignedInteger('http_status')->nullable();
// Metadata
$table->string('ip_address', 45)->nullable();
$table->string('user_agent', 500)->nullable();
$table->string('request_id', 100)->nullable()->comment('Unique request identifier');
$table->timestamps();
$table->index('user_id');
$table->index(['provider', 'model']);
$table->index('status');
$table->index('created_at');
$table->index('request_id');
$table->index(['user_id', 'created_at', 'total_cost']);
});
}
public function down(): void
{
Schema::dropIfExists('llm_requests');
}
};

View File

@@ -0,0 +1,44 @@
<?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::create('model_pricing', function (Blueprint $table) {
$table->id();
$table->string('provider', 50);
$table->string('model', 100);
// Pricing (per 1M tokens)
$table->decimal('input_price_per_million', 10, 4)->comment('Cost per 1M input tokens in USD');
$table->decimal('output_price_per_million', 10, 4)->comment('Cost per 1M output tokens in USD');
// Model Information
$table->unsignedInteger('context_window')->nullable()->comment('Maximum context size');
$table->unsignedInteger('max_output_tokens')->nullable()->comment('Maximum output tokens');
// Metadata
$table->boolean('is_active')->default(true);
$table->date('effective_from')->default(DB::raw('CURRENT_DATE'));
$table->date('effective_until')->nullable();
$table->text('notes')->nullable();
$table->timestamps();
$table->unique(['provider', 'model', 'effective_from']);
$table->index('provider');
$table->index('model');
$table->index('is_active');
$table->index(['effective_from', 'effective_until']);
});
}
public function down(): void
{
Schema::dropIfExists('model_pricing');
}
};

View File

@@ -0,0 +1,48 @@
<?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::create('user_budgets', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->onDelete('cascade');
// Budget Configuration
$table->decimal('monthly_limit', 10, 2)->default(0)->comment('Monthly spending limit in USD');
$table->decimal('daily_limit', 10, 2)->nullable()->comment('Optional daily limit');
// Current Period Tracking
$table->decimal('current_month_spending', 10, 2)->default(0);
$table->decimal('current_day_spending', 10, 2)->default(0);
// Period Timestamps
$table->date('month_started_at');
$table->date('day_started_at');
// Alert Thresholds
$table->unsignedInteger('alert_threshold_percentage')->default(80)->comment('Alert at X% of limit');
$table->timestamp('last_alert_sent_at')->nullable();
// Status
$table->boolean('is_budget_exceeded')->default(false);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->unique('user_id');
$table->index('is_active');
$table->index('is_budget_exceeded');
$table->index('current_month_spending');
});
}
public function down(): void
{
Schema::dropIfExists('user_budgets');
}
};

View File

@@ -0,0 +1,46 @@
<?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::create('rate_limits', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->onDelete('cascade');
// Rate Limit Configuration
$table->unsignedInteger('requests_per_minute')->default(60);
$table->unsignedInteger('requests_per_hour')->default(1000);
$table->unsignedInteger('requests_per_day')->default(10000);
// Current Period Counters
$table->unsignedInteger('current_minute_count')->default(0);
$table->unsignedInteger('current_hour_count')->default(0);
$table->unsignedInteger('current_day_count')->default(0);
// Period Timestamps
$table->timestamp('minute_started_at')->useCurrent();
$table->timestamp('hour_started_at')->useCurrent();
$table->timestamp('day_started_at')->useCurrent();
// Status
$table->boolean('is_rate_limited')->default(false);
$table->timestamp('rate_limit_expires_at')->nullable();
$table->timestamps();
$table->unique('user_id');
$table->index('is_rate_limited');
$table->index('rate_limit_expires_at');
});
}
public function down(): void
{
Schema::dropIfExists('rate_limits');
}
};

View File

@@ -0,0 +1,159 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$now = now();
DB::table('model_pricing')->insert([
// Mistral AI Models
[
'provider' => 'mistral',
'model' => 'mistral-large-latest',
'input_price_per_million' => 2.00,
'output_price_per_million' => 6.00,
'context_window' => 128000,
'max_output_tokens' => 4096,
'is_active' => true,
'effective_from' => $now,
'created_at' => $now,
'updated_at' => $now,
],
[
'provider' => 'mistral',
'model' => 'mistral-medium-latest',
'input_price_per_million' => 2.70,
'output_price_per_million' => 8.10,
'context_window' => 32000,
'max_output_tokens' => 4096,
'is_active' => true,
'effective_from' => $now,
'created_at' => $now,
'updated_at' => $now,
],
[
'provider' => 'mistral',
'model' => 'mistral-small-latest',
'input_price_per_million' => 0.20,
'output_price_per_million' => 0.60,
'context_window' => 32000,
'max_output_tokens' => 4096,
'is_active' => true,
'effective_from' => $now,
'created_at' => $now,
'updated_at' => $now,
],
[
'provider' => 'mistral',
'model' => 'open-mistral-7b',
'input_price_per_million' => 0.25,
'output_price_per_million' => 0.25,
'context_window' => 32000,
'max_output_tokens' => 4096,
'is_active' => true,
'effective_from' => $now,
'created_at' => $now,
'updated_at' => $now,
],
[
'provider' => 'mistral',
'model' => 'open-mixtral-8x7b',
'input_price_per_million' => 0.70,
'output_price_per_million' => 0.70,
'context_window' => 32000,
'max_output_tokens' => 4096,
'is_active' => true,
'effective_from' => $now,
'created_at' => $now,
'updated_at' => $now,
],
// Google Gemini Models
[
'provider' => 'gemini',
'model' => 'gemini-pro',
'input_price_per_million' => 0.50,
'output_price_per_million' => 1.50,
'context_window' => 32760,
'max_output_tokens' => 2048,
'is_active' => true,
'effective_from' => $now,
'created_at' => $now,
'updated_at' => $now,
],
[
'provider' => 'gemini',
'model' => 'gemini-1.5-pro',
'input_price_per_million' => 3.50,
'output_price_per_million' => 10.50,
'context_window' => 2097152,
'max_output_tokens' => 8192,
'is_active' => true,
'effective_from' => $now,
'created_at' => $now,
'updated_at' => $now,
],
[
'provider' => 'gemini',
'model' => 'gemini-1.5-flash',
'input_price_per_million' => 0.35,
'output_price_per_million' => 1.05,
'context_window' => 1048576,
'max_output_tokens' => 8192,
'is_active' => true,
'effective_from' => $now,
'created_at' => $now,
'updated_at' => $now,
],
// DeepSeek Models
[
'provider' => 'deepseek',
'model' => 'deepseek-chat',
'input_price_per_million' => 0.14,
'output_price_per_million' => 0.28,
'context_window' => 64000,
'max_output_tokens' => 4096,
'is_active' => true,
'effective_from' => $now,
'created_at' => $now,
'updated_at' => $now,
],
[
'provider' => 'deepseek',
'model' => 'deepseek-coder',
'input_price_per_million' => 0.14,
'output_price_per_million' => 0.28,
'context_window' => 128000,
'max_output_tokens' => 4096,
'is_active' => true,
'effective_from' => $now,
'created_at' => $now,
'updated_at' => $now,
],
[
'provider' => 'deepseek',
'model' => 'deepseek-reasoner',
'input_price_per_million' => 0.55,
'output_price_per_million' => 2.19,
'context_window' => 64000,
'max_output_tokens' => 8192,
'is_active' => true,
'effective_from' => $now,
'created_at' => $now,
'updated_at' => $now,
],
]);
}
public function down(): void
{
DB::table('model_pricing')
->whereIn('provider', ['mistral', 'gemini', 'deepseek'])
->delete();
}
};

View File

@@ -0,0 +1,29 @@
<?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::create('gateway_users', function (Blueprint $table) {
$table->string('user_id')->primary();
$table->string('alias')->nullable();
$table->string('budget_id')->nullable();
$table->decimal('spend', 10, 2)->default(0);
$table->boolean('blocked')->default(false);
$table->json('metadata')->nullable();
$table->timestamps();
$table->index('blocked');
$table->index('created_at');
});
}
public function down(): void
{
Schema::dropIfExists('gateway_users');
}
};

View File

@@ -0,0 +1,27 @@
<?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::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');
});
}
public function down(): void
{
Schema::dropIfExists('budgets');
}
};

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::create('api_keys', function (Blueprint $table) {
$table->string('token')->primary();
$table->string('user_id');
$table->string('key_alias')->nullable();
$table->string('key_name')->nullable();
$table->json('permissions')->nullable();
$table->json('models')->nullable();
$table->json('metadata')->nullable();
$table->timestamp('expires')->nullable();
$table->timestamps();
$table->index('user_id');
$table->index('expires');
$table->foreign('user_id')
->references('user_id')
->on('gateway_users')
->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('api_keys');
}
};

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
{
public function up(): void
{
Schema::create('usage_logs', function (Blueprint $table) {
$table->string('request_id')->primary();
$table->string('user_id');
$table->string('api_key');
$table->string('model');
$table->string('provider')->nullable();
$table->integer('prompt_tokens')->default(0);
$table->integer('completion_tokens')->default(0);
$table->integer('total_tokens')->default(0);
$table->decimal('cost', 10, 6)->default(0);
$table->timestamp('timestamp')->useCurrent();
$table->json('metadata')->nullable();
$table->index('user_id');
$table->index('api_key');
$table->index('model');
$table->index('timestamp');
$table->foreign('user_id')
->references('user_id')
->on('gateway_users')
->onDelete('cascade');
$table->foreign('api_key')
->references('token')
->on('api_keys')
->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('usage_logs');
}
};

View File

@@ -0,0 +1,28 @@
<?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('usage_logs', function (Blueprint $table) {
$table->string('status')->default('success')->after('cost');
$table->string('endpoint')->nullable()->after('provider');
$table->text('error_message')->nullable()->after('status');
// Add index for status for better query performance
$table->index('status');
});
}
public function down(): void
{
Schema::table('usage_logs', function (Blueprint $table) {
$table->dropIndex(['status']);
$table->dropColumn(['status', 'endpoint', 'error_message']);
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class AdminUserSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
User::firstOrCreate(
['email' => 'admin@example.com'],
[
'name' => 'Admin User',
'password' => Hash::make('password'),
'email_verified_at' => now(),
]
);
$this->command->info('Admin user created successfully!');
$this->command->info('Email: admin@example.com');
$this->command->info('Password: password');
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class ModelPricingSeeder extends Seeder
{
public function run(): void
{
$now = Carbon::now();
$pricingData = [
// OpenAI Models
[
'provider' => 'openai',
'model' => 'gpt-4o',
'input_price_per_million' => 2.50,
'output_price_per_million' => 10.00,
'context_window' => 128000,
'max_output_tokens' => 16384,
'is_active' => true,
'effective_from' => $now->toDateString(),
'notes' => 'GPT-4 Omni - Most capable model',
'created_at' => $now,
'updated_at' => $now,
],
[
'provider' => 'openai',
'model' => 'gpt-4o-mini',
'input_price_per_million' => 0.15,
'output_price_per_million' => 0.60,
'context_window' => 128000,
'max_output_tokens' => 16384,
'is_active' => true,
'effective_from' => $now->toDateString(),
'notes' => 'Cost-efficient model for simple tasks',
'created_at' => $now,
'updated_at' => $now,
],
[
'provider' => 'openai',
'model' => 'gpt-4-turbo',
'input_price_per_million' => 10.00,
'output_price_per_million' => 30.00,
'context_window' => 128000,
'max_output_tokens' => 4096,
'is_active' => true,
'effective_from' => $now->toDateString(),
'notes' => 'GPT-4 Turbo with vision capabilities',
'created_at' => $now,
'updated_at' => $now,
],
[
'provider' => 'openai',
'model' => 'gpt-3.5-turbo',
'input_price_per_million' => 0.50,
'output_price_per_million' => 1.50,
'context_window' => 16385,
'max_output_tokens' => 4096,
'is_active' => true,
'effective_from' => $now->toDateString(),
'notes' => 'Fast and affordable legacy model',
'created_at' => $now,
'updated_at' => $now,
],
// Anthropic Models
[
'provider' => 'anthropic',
'model' => 'claude-opus-4',
'input_price_per_million' => 15.00,
'output_price_per_million' => 75.00,
'context_window' => 200000,
'max_output_tokens' => 4096,
'is_active' => true,
'effective_from' => $now->toDateString(),
'notes' => 'Most capable Claude model',
'created_at' => $now,
'updated_at' => $now,
],
[
'provider' => 'anthropic',
'model' => 'claude-sonnet-4',
'input_price_per_million' => 3.00,
'output_price_per_million' => 15.00,
'context_window' => 200000,
'max_output_tokens' => 8192,
'is_active' => true,
'effective_from' => $now->toDateString(),
'notes' => 'Balanced performance and cost',
'created_at' => $now,
'updated_at' => $now,
],
[
'provider' => 'anthropic',
'model' => 'claude-haiku-4',
'input_price_per_million' => 0.25,
'output_price_per_million' => 1.25,
'context_window' => 200000,
'max_output_tokens' => 4096,
'is_active' => true,
'effective_from' => $now->toDateString(),
'notes' => 'Fast and cost-effective',
'created_at' => $now,
'updated_at' => $now,
],
// Mistral AI Models
[
'provider' => 'mistral',
'model' => 'mistral-large',
'input_price_per_million' => 2.00,
'output_price_per_million' => 6.00,
'context_window' => 128000,
'max_output_tokens' => 4096,
'is_active' => true,
'effective_from' => $now->toDateString(),
'notes' => 'Most capable Mistral model',
'created_at' => $now,
'updated_at' => $now,
],
[
'provider' => 'mistral',
'model' => 'mistral-medium',
'input_price_per_million' => 2.70,
'output_price_per_million' => 8.10,
'context_window' => 32000,
'max_output_tokens' => 4096,
'is_active' => true,
'effective_from' => $now->toDateString(),
'notes' => 'Balanced Mistral model',
'created_at' => $now,
'updated_at' => $now,
],
[
'provider' => 'mistral',
'model' => 'mistral-small',
'input_price_per_million' => 0.20,
'output_price_per_million' => 0.60,
'context_window' => 32000,
'max_output_tokens' => 4096,
'is_active' => true,
'effective_from' => $now->toDateString(),
'notes' => 'Cost-effective Mistral model',
'created_at' => $now,
'updated_at' => $now,
],
];
DB::table('model_pricing')->insert($pricingData);
$this->command->info('Model pricing data seeded successfully!');
$this->command->info('Total models: ' . count($pricingData));
}
}

View File

@@ -0,0 +1,198 @@
<x-app-layout>
<x-slot name="header">
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Add Provider Credentials') }}
</h2>
<a href="{{ route('admin.credentials.index') }}" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
Back to List
</a>
</div>
</x-slot>
<div class="py-12">
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
@if(session('error'))
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{{ session('error') }}
</div>
@endif
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<form method="POST" action="{{ route('admin.credentials.store') }}" class="space-y-6">
@csrf
<!-- User Selection -->
<div>
<label for="user_id" class="block text-sm font-medium text-gray-700 mb-2">
User <span class="text-red-500">*</span>
</label>
<select
name="user_id"
id="user_id"
required
class="w-full rounded-md border-gray-300 @error('user_id') border-red-500 @enderror"
>
<option value="">Select a user</option>
@foreach($users as $user)
<option value="{{ $user->id }}" {{ old('user_id') == $user->id ? 'selected' : '' }}>
{{ $user->name }} ({{ $user->email }})
</option>
@endforeach
</select>
@error('user_id')
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
@enderror
</div>
<!-- Provider Selection -->
<div>
<label for="provider" class="block text-sm font-medium text-gray-700 mb-2">
AI Provider <span class="text-red-500">*</span>
</label>
<select
name="provider"
id="provider"
required
class="w-full rounded-md border-gray-300 @error('provider') border-red-500 @enderror"
onchange="updateProviderHelp()"
>
<option value="">Select a provider</option>
@foreach($providers as $key => $label)
<option value="{{ $key }}" {{ old('provider') == $key ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
@error('provider')
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
@enderror
<!-- Provider-specific help text -->
<div id="provider-help" class="mt-2 text-sm text-gray-600">
<p class="hidden" data-provider="openai">
📝 Get your API key from: <a href="https://platform.openai.com/api-keys" target="_blank" class="text-blue-600 hover:underline">OpenAI Dashboard</a>
</p>
<p class="hidden" data-provider="anthropic">
📝 Get your API key from: <a href="https://console.anthropic.com/settings/keys" target="_blank" class="text-blue-600 hover:underline">Anthropic Console</a>
</p>
<p class="hidden" data-provider="mistral">
📝 Get your API key from: <a href="https://console.mistral.ai/api-keys" target="_blank" class="text-blue-600 hover:underline">Mistral Console</a>
</p>
<p class="hidden" data-provider="gemini">
📝 Get your API key from: <a href="https://makersuite.google.com/app/apikey" target="_blank" class="text-blue-600 hover:underline">Google AI Studio</a>
</p>
<p class="hidden" data-provider="deepseek">
📝 Get your API key from: <a href="https://platform.deepseek.com/api_keys" target="_blank" class="text-blue-600 hover:underline">DeepSeek Platform</a>
</p>
</div>
</div>
<!-- API Key -->
<div>
<label for="api_key" class="block text-sm font-medium text-gray-700 mb-2">
API Key <span class="text-red-500">*</span>
</label>
<input
type="password"
name="api_key"
id="api_key"
required
placeholder="sk-..."
class="w-full rounded-md border-gray-300 @error('api_key') border-red-500 @enderror"
value="{{ old('api_key') }}"
>
@error('api_key')
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
@enderror
<p class="text-xs text-gray-500 mt-1">
🔒 The API key will be encrypted before storage
</p>
</div>
<!-- Organization ID (Optional) -->
<div>
<label for="organization_id" class="block text-sm font-medium text-gray-700 mb-2">
Organization ID <span class="text-gray-400">(optional)</span>
</label>
<input
type="text"
name="organization_id"
id="organization_id"
placeholder="org-..."
class="w-full rounded-md border-gray-300 @error('organization_id') border-red-500 @enderror"
value="{{ old('organization_id') }}"
>
@error('organization_id')
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
@enderror
<p class="text-xs text-gray-500 mt-1">
Required for some OpenAI enterprise accounts
</p>
</div>
<!-- Active Status -->
<div class="flex items-center">
<input
type="checkbox"
name="is_active"
id="is_active"
value="1"
checked
class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
>
<label for="is_active" class="ml-2 block text-sm text-gray-900">
Active (enable for immediate use)
</label>
</div>
<!-- Submit Button -->
<div class="flex items-center justify-end space-x-3">
<a href="{{ route('admin.credentials.index') }}" class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded">
Cancel
</a>
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Add Credentials
</button>
</div>
</form>
</div>
</div>
<!-- Info Box -->
<div class="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 class="font-semibold text-blue-900 mb-2"> Important Information</h3>
<ul class="text-sm text-blue-800 space-y-1 list-disc list-inside">
<li>Each user can only have one set of credentials per provider</li>
<li>API keys are encrypted using Laravel's encryption (AES-256-CBC)</li>
<li>You can test credentials after creation to verify they work</li>
<li>Usage and costs will be tracked per user and provider</li>
</ul>
</div>
</div>
</div>
@push('scripts')
<script>
function updateProviderHelp() {
const provider = document.getElementById('provider').value;
const helpTexts = document.querySelectorAll('#provider-help p');
helpTexts.forEach(text => text.classList.add('hidden'));
if (provider) {
const selectedHelp = document.querySelector(`#provider-help p[data-provider="${provider}"]`);
if (selectedHelp) {
selectedHelp.classList.remove('hidden');
}
}
}
// Initialize on page load if provider is already selected
document.addEventListener('DOMContentLoaded', function() {
updateProviderHelp();
});
</script>
@endpush
</x-app-layout>

View File

@@ -0,0 +1,208 @@
<x-app-layout>
<x-slot name="header">
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Edit Provider Credentials') }}
</h2>
<div class="space-x-2">
<a href="{{ route('admin.credentials.show', $credential) }}" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
View Details
</a>
<a href="{{ route('admin.credentials.index') }}" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
Back to List
</a>
</div>
</div>
</x-slot>
<div class="py-12">
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
@if(session('error'))
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{{ session('error') }}
</div>
@endif
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<form method="POST" action="{{ route('admin.credentials.update', $credential) }}" class="space-y-6">
@csrf
@method('PUT')
<!-- User (Read-only) -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
User
</label>
<div class="w-full px-3 py-2 bg-gray-100 rounded-md border border-gray-300">
<div class="font-medium">{{ $credential->user->name }}</div>
<div class="text-sm text-gray-500">{{ $credential->user->email }}</div>
</div>
<p class="text-xs text-gray-500 mt-1">
User cannot be changed after creation
</p>
</div>
<!-- Provider (Read-only) -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
AI Provider
</label>
<div class="w-full px-3 py-2 bg-gray-100 rounded-md border border-gray-300">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
@if($credential->provider == 'openai') bg-green-100 text-green-800
@elseif($credential->provider == 'anthropic') bg-purple-100 text-purple-800
@elseif($credential->provider == 'mistral') bg-blue-100 text-blue-800
@elseif($credential->provider == 'gemini') bg-yellow-100 text-yellow-800
@else bg-gray-100 text-gray-800
@endif">
{{ $providers[$credential->provider] ?? ucfirst($credential->provider) }}
</span>
</div>
<p class="text-xs text-gray-500 mt-1">
Provider cannot be changed after creation
</p>
</div>
<!-- API Key (Update) -->
<div>
<label for="api_key" class="block text-sm font-medium text-gray-700 mb-2">
API Key <span class="text-gray-400">(leave empty to keep current)</span>
</label>
<input
type="password"
name="api_key"
id="api_key"
placeholder="sk-... (enter new key to update)"
class="w-full rounded-md border-gray-300 @error('api_key') border-red-500 @enderror"
value="{{ old('api_key') }}"
>
@error('api_key')
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
@enderror
<p class="text-xs text-gray-500 mt-1">
🔒 Current API key is encrypted and hidden. Enter a new key only if you want to update it.
</p>
</div>
<!-- Organization ID -->
<div>
<label for="organization_id" class="block text-sm font-medium text-gray-700 mb-2">
Organization ID <span class="text-gray-400">(optional)</span>
</label>
<input
type="text"
name="organization_id"
id="organization_id"
placeholder="org-..."
class="w-full rounded-md border-gray-300 @error('organization_id') border-red-500 @enderror"
value="{{ old('organization_id', $credential->organization_id) }}"
>
@error('organization_id')
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
@enderror
<p class="text-xs text-gray-500 mt-1">
Required for some OpenAI enterprise accounts
</p>
</div>
<!-- Active Status -->
<div class="flex items-center">
<input
type="checkbox"
name="is_active"
id="is_active"
value="1"
{{ old('is_active', $credential->is_active) ? 'checked' : '' }}
class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
>
<label for="is_active" class="ml-2 block text-sm text-gray-900">
Active (enable for use in requests)
</label>
</div>
<!-- Metadata Info -->
<div class="bg-gray-50 rounded-lg p-4">
<h3 class="font-semibold text-gray-900 mb-2">Metadata</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-600">Created:</span>
<span class="font-medium">{{ $credential->created_at->format('M d, Y H:i') }}</span>
</div>
<div>
<span class="text-gray-600">Last Updated:</span>
<span class="font-medium">{{ $credential->updated_at->format('M d, Y H:i') }}</span>
</div>
<div>
<span class="text-gray-600">Last Used:</span>
<span class="font-medium">{{ $credential->last_used_at ? $credential->last_used_at->diffForHumans() : 'Never' }}</span>
</div>
</div>
</div>
<!-- Submit Button -->
<div class="flex items-center justify-end space-x-3">
<a href="{{ route('admin.credentials.index') }}" class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded">
Cancel
</a>
<button
type="button"
onclick="testCredential({{ $credential->id }})"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
🧪 Test Current Key
</button>
<button type="submit" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">
Update Credentials
</button>
</div>
</form>
</div>
</div>
<!-- Info Box -->
<div class="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h3 class="font-semibold text-yellow-900 mb-2">⚠️ Important Notes</h3>
<ul class="text-sm text-yellow-800 space-y-1 list-disc list-inside">
<li>User and Provider cannot be changed once created</li>
<li>Test the API key before saving to ensure it works</li>
<li>Old API key will be replaced if you enter a new one</li>
<li>Disabling credentials will prevent any API requests using this key</li>
</ul>
</div>
</div>
</div>
@push('scripts')
<script>
function testCredential(credentialId) {
const button = event.target;
const originalText = button.textContent;
button.textContent = '🔄 Testing...';
button.disabled = true;
fetch(`/admin/credentials/${credentialId}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`✅ Success!\n\n${data.message}\n${data.details || ''}`);
} else {
alert(`❌ Failed!\n\n${data.message}`);
}
})
.catch(error => {
alert(`❌ Error!\n\n${error.message}`);
})
.finally(() => {
button.textContent = originalText;
button.disabled = false;
});
}
</script>
@endpush
</x-app-layout>

View File

@@ -0,0 +1,230 @@
<x-app-layout>
<x-slot name="header">
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Provider Credentials') }}
</h2>
<a href="{{ route('admin.credentials.create') }}" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Add New Credentials
</a>
</div>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<!-- Success/Error Messages -->
@if(session('success'))
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{{ session('success') }}
</div>
@endif
@if(session('error'))
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{{ session('error') }}
</div>
@endif
<!-- Filters -->
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg mb-6">
<div class="p-6">
<form method="GET" action="{{ route('admin.credentials.index') }}" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- Provider Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Provider</label>
<select name="provider" class="w-full rounded-md border-gray-300">
<option value="">All Providers</option>
@foreach($providers as $provider)
<option value="{{ $provider }}" {{ request('provider') == $provider ? 'selected' : '' }}>
{{ ucfirst($provider) }}
</option>
@endforeach
</select>
</div>
<!-- User Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">User</label>
<select name="user_id" class="w-full rounded-md border-gray-300">
<option value="">All Users</option>
@foreach($users as $user)
<option value="{{ $user->id }}" {{ request('user_id') == $user->id ? 'selected' : '' }}>
{{ $user->name }}
</option>
@endforeach
</select>
</div>
<!-- Status Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
<select name="status" class="w-full rounded-md border-gray-300">
<option value="">All Status</option>
<option value="active" {{ request('status') == 'active' ? 'selected' : '' }}>Active</option>
<option value="inactive" {{ request('status') == 'inactive' ? 'selected' : '' }}>Inactive</option>
</select>
</div>
<!-- Search -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Search User</label>
<input
type="text"
name="search"
value="{{ request('search') }}"
placeholder="Name or email"
class="w-full rounded-md border-gray-300"
>
</div>
</div>
<div class="flex space-x-2">
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Apply Filters
</button>
<a href="{{ route('admin.credentials.index') }}" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
Reset
</a>
</div>
</form>
</div>
</div>
<!-- Credentials Table -->
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Provider
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Organization ID
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Used
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($credentials as $credential)
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ $credential->user->name }}</div>
<div class="text-sm text-gray-500">{{ $credential->user->email }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
@if($credential->provider == 'openai') bg-green-100 text-green-800
@elseif($credential->provider == 'anthropic') bg-purple-100 text-purple-800
@elseif($credential->provider == 'mistral') bg-blue-100 text-blue-800
@elseif($credential->provider == 'gemini') bg-yellow-100 text-yellow-800
@else bg-gray-100 text-gray-800
@endif">
{{ ucfirst($credential->provider) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $credential->organization_id ?? '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if($credential->is_active)
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
Active
</span>
@else
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
Inactive
</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $credential->last_used_at ? $credential->last_used_at->diffForHumans() : 'Never' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button
onclick="testCredential({{ $credential->id }})"
class="text-blue-600 hover:text-blue-900"
title="Test API Key">
🧪 Test
</button>
<a href="{{ route('admin.credentials.show', $credential) }}" class="text-indigo-600 hover:text-indigo-900">
View
</a>
<a href="{{ route('admin.credentials.edit', $credential) }}" class="text-yellow-600 hover:text-yellow-900">
Edit
</a>
<form action="{{ route('admin.credentials.destroy', $credential) }}" method="POST" class="inline" onsubmit="return confirm('Are you sure you want to delete these credentials?');">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:text-red-900">
Delete
</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-6 py-4 text-center text-gray-500">
No credentials found. <a href="{{ route('admin.credentials.create') }}" class="text-blue-600 hover:text-blue-900">Add your first credentials</a>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="mt-4">
{{ $credentials->links() }}
</div>
</div>
</div>
</div>
</div>
@push('scripts')
<script>
function testCredential(credentialId) {
const button = event.target;
button.textContent = '🔄 Testing...';
button.disabled = true;
fetch(`/admin/credentials/${credentialId}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`✅ Success!\n\n${data.message}\n${data.details || ''}`);
} else {
alert(`❌ Failed!\n\n${data.message}`);
}
})
.catch(error => {
alert(`❌ Error!\n\n${error.message}`);
})
.finally(() => {
button.textContent = '🧪 Test';
button.disabled = false;
});
}
</script>
@endpush
</x-app-layout>

View File

@@ -0,0 +1,232 @@
<x-app-layout>
<x-slot name="header">
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Provider Credentials Details') }}
</h2>
<div class="space-x-2">
<button
onclick="testCredential({{ $credential->id }})"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
🧪 Test API Key
</button>
<a href="{{ route('admin.credentials.edit', $credential) }}" class="bg-yellow-500 hover:bg-yellow-700 text-white font-bold py-2 px-4 rounded">
Edit
</a>
<a href="{{ route('admin.credentials.index') }}" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
Back to List
</a>
</div>
</div>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<!-- Basic Information -->
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">Basic Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- User -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">User</label>
<div class="text-lg font-semibold">{{ $credential->user->name }}</div>
<div class="text-sm text-gray-500">{{ $credential->user->email }}</div>
</div>
<!-- Provider -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Provider</label>
<span class="inline-flex items-center px-4 py-2 rounded-full text-base font-medium
@if($credential->provider == 'openai') bg-green-100 text-green-800
@elseif($credential->provider == 'anthropic') bg-purple-100 text-purple-800
@elseif($credential->provider == 'mistral') bg-blue-100 text-blue-800
@elseif($credential->provider == 'gemini') bg-yellow-100 text-yellow-800
@else bg-gray-100 text-gray-800
@endif">
{{ ucfirst($credential->provider) }}
</span>
</div>
<!-- Organization ID -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Organization ID</label>
<div class="text-base">{{ $credential->organization_id ?? 'Not set' }}</div>
</div>
<!-- Status -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
@if($credential->is_active)
<span class="px-4 inline-flex text-base leading-7 font-semibold rounded-full bg-green-100 text-green-800">
Active
</span>
@else
<span class="px-4 inline-flex text-base leading-7 font-semibold rounded-full bg-red-100 text-red-800">
Inactive
</span>
@endif
</div>
<!-- Created At -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Created</label>
<div class="text-base">{{ $credential->created_at->format('M d, Y H:i') }}</div>
<div class="text-sm text-gray-500">{{ $credential->created_at->diffForHumans() }}</div>
</div>
<!-- Last Used -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Last Used</label>
<div class="text-base">
@if($credential->last_used_at)
{{ $credential->last_used_at->format('M d, Y H:i') }}
<div class="text-sm text-gray-500">{{ $credential->last_used_at->diffForHumans() }}</div>
@else
<span class="text-gray-500">Never used</span>
@endif
</div>
</div>
</div>
</div>
</div>
<!-- Usage Statistics -->
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">Usage Statistics</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- Total Requests -->
<div class="bg-blue-50 rounded-lg p-4">
<div class="text-sm text-blue-600 font-medium">Total Requests</div>
<div class="text-2xl font-bold text-blue-900">{{ number_format($stats['total_requests']) }}</div>
</div>
<!-- Total Cost -->
<div class="bg-green-50 rounded-lg p-4">
<div class="text-sm text-green-600 font-medium">Total Cost</div>
<div class="text-2xl font-bold text-green-900">${{ number_format($stats['total_cost'], 2) }}</div>
</div>
<!-- Total Tokens -->
<div class="bg-purple-50 rounded-lg p-4">
<div class="text-sm text-purple-600 font-medium">Total Tokens</div>
<div class="text-2xl font-bold text-purple-900">{{ number_format($stats['total_tokens']) }}</div>
</div>
<!-- Last 30 Days -->
<div class="bg-yellow-50 rounded-lg p-4">
<div class="text-sm text-yellow-600 font-medium">Last 30 Days</div>
<div class="text-2xl font-bold text-yellow-900">{{ number_format($stats['last_30_days_requests']) }}</div>
</div>
</div>
</div>
</div>
<!-- Security Information -->
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">Security Information</h3>
<div class="space-y-3">
<div class="flex items-start">
<span class="text-green-500 text-xl mr-3">🔒</span>
<div>
<div class="font-medium">Encryption Status</div>
<div class="text-sm text-gray-600">API key is encrypted using AES-256-CBC encryption</div>
</div>
</div>
<div class="flex items-start">
<span class="text-blue-500 text-xl mr-3">🔑</span>
<div>
<div class="font-medium">API Key Format</div>
<div class="text-sm text-gray-600">
<code class="bg-gray-100 px-2 py-1 rounded">{{ $credential->provider }}-*********************</code>
(hidden for security)
</div>
</div>
</div>
<div class="flex items-start">
<span class="text-purple-500 text-xl mr-3">📊</span>
<div>
<div class="font-medium">Usage Tracking</div>
<div class="text-sm text-gray-600">All requests using this credential are logged and tracked</div>
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">Actions</h3>
<div class="flex space-x-3">
<button
onclick="testCredential({{ $credential->id }})"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
🧪 Test API Key
</button>
<a href="{{ route('admin.credentials.edit', $credential) }}" class="bg-yellow-500 hover:bg-yellow-700 text-white font-bold py-2 px-4 rounded">
✏️ Edit Credentials
</a>
<form
action="{{ route('admin.credentials.destroy', $credential) }}"
method="POST"
onsubmit="return confirm('Are you sure you want to delete these credentials? This action cannot be undone.');"
class="inline">
@csrf
@method('DELETE')
<button type="submit" class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded">
🗑️ Delete Credentials
</button>
</form>
</div>
</div>
</div>
</div>
</div>
@push('scripts')
<script>
function testCredential(credentialId) {
const button = event.target;
const originalText = button.textContent;
button.textContent = '🔄 Testing...';
button.disabled = true;
fetch(`/admin/credentials/${credentialId}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`✅ Success!\n\n${data.message}\n${data.details || ''}`);
} else {
alert(`❌ Failed!\n\n${data.message}`);
}
})
.catch(error => {
alert(`❌ Error!\n\n${error.message}`);
})
.finally(() => {
button.textContent = originalText;
button.disabled = false;
});
}
</script>
@endpush
</x-app-layout>

View File

@@ -0,0 +1,254 @@
<x-app-layout>
<x-slot name="header">
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
Budget & Rate Limits - {{ $user->name }}
</h2>
</div>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<!-- Success Messages -->
@if(session('success'))
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">
{{ session('success') }}
</div>
@endif
<!-- User Info -->
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">User Information</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Name</label>
<div class="text-lg">{{ $user->name }}</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Email</label>
<div class="text-lg">{{ $user->email }}</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Member Since</label>
<div class="text-lg">{{ $user->created_at->format('M d, Y') }}</div>
</div>
</div>
</div>
</div>
<!-- Budget Status -->
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">Budget Status</h3>
<form action="{{ route('admin.users.budget.reset', $user) }}" method="POST"
onsubmit="return confirm('Are you sure you want to reset this user\'s budget?');">
@csrf
<button type="submit" class="bg-yellow-500 hover:bg-yellow-700 text-white font-bold py-2 px-4 rounded text-sm">
Reset Budget
</button>
</form>
</div>
<!-- Budget Overview -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-blue-50 rounded-lg p-4">
<div class="text-sm text-blue-600 font-medium">Monthly Limit</div>
<div class="text-2xl font-bold text-blue-900">${{ number_format($budgetStatus['monthly_limit'], 2) }}</div>
</div>
<div class="bg-green-50 rounded-lg p-4">
<div class="text-sm text-green-600 font-medium">Daily Limit</div>
<div class="text-2xl font-bold text-green-900">${{ number_format($budgetStatus['daily_limit'], 2) }}</div>
</div>
<div class="bg-orange-50 rounded-lg p-4">
<div class="text-sm text-orange-600 font-medium">Month Spending</div>
<div class="text-2xl font-bold text-orange-900">${{ number_format($budgetStatus['current_month_spending'], 2) }}</div>
<div class="text-xs text-orange-700">{{ round($budgetStatus['monthly_usage_percentage'], 1) }}% used</div>
</div>
<div class="bg-purple-50 rounded-lg p-4">
<div class="text-sm text-purple-600 font-medium">Today Spending</div>
<div class="text-2xl font-bold text-purple-900">${{ number_format($budgetStatus['current_day_spending'], 2) }}</div>
</div>
</div>
<!-- Progress Bars -->
<div class="space-y-4 mb-6">
<div>
<div class="flex justify-between mb-1">
<span class="text-sm font-medium">Monthly Budget Usage</span>
<span class="text-sm font-medium">{{ round($budgetStatus['monthly_usage_percentage'], 1) }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div class="h-2.5 rounded-full {{ $budgetStatus['is_exceeded'] ? 'bg-red-600' : ($budgetStatus['monthly_usage_percentage'] >= 80 ? 'bg-yellow-500' : 'bg-blue-600') }}"
style="width: {{ min(100, $budgetStatus['monthly_usage_percentage']) }}%"></div>
</div>
</div>
</div>
<!-- Edit Budget Form -->
<form action="{{ route('admin.users.budget.update', $user) }}" method="POST" class="space-y-4">
@csrf
@method('PUT')
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="monthly_limit" class="block text-sm font-medium text-gray-700 mb-2">
Monthly Limit ($)
</label>
<input
type="number"
step="0.01"
name="monthly_limit"
id="monthly_limit"
value="{{ $budgetStatus['monthly_limit'] }}"
class="w-full rounded-md border-gray-300"
>
</div>
<div>
<label for="daily_limit" class="block text-sm font-medium text-gray-700 mb-2">
Daily Limit ($)
</label>
<input
type="number"
step="0.01"
name="daily_limit"
id="daily_limit"
value="{{ $budgetStatus['daily_limit'] }}"
class="w-full rounded-md border-gray-300"
>
</div>
<div>
<label for="alert_threshold_percentage" class="block text-sm font-medium text-gray-700 mb-2">
Alert Threshold (%)
</label>
<input
type="number"
name="alert_threshold_percentage"
id="alert_threshold_percentage"
value="80"
min="0"
max="100"
class="w-full rounded-md border-gray-300"
>
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Update Budget Limits
</button>
</div>
</form>
</div>
</div>
<!-- Rate Limit Status -->
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">Rate Limit Status</h3>
<form action="{{ route('admin.users.rate-limit.reset', $user) }}" method="POST"
onsubmit="return confirm('Are you sure you want to reset this user\'s rate limits?');">
@csrf
<button type="submit" class="bg-yellow-500 hover:bg-yellow-700 text-white font-bold py-2 px-4 rounded text-sm">
Reset Rate Limits
</button>
</form>
</div>
<!-- Rate Limit Overview -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-blue-50 rounded-lg p-4">
<div class="text-sm text-blue-600 font-medium">Per Minute</div>
<div class="text-2xl font-bold text-blue-900">
{{ $rateLimitStatus['current_minute_count'] }} / {{ $rateLimitStatus['requests_per_minute'] }}
</div>
<div class="text-xs text-blue-700">{{ $rateLimitStatus['minute_remaining'] }} remaining</div>
</div>
<div class="bg-green-50 rounded-lg p-4">
<div class="text-sm text-green-600 font-medium">Per Hour</div>
<div class="text-2xl font-bold text-green-900">
{{ $rateLimitStatus['current_hour_count'] }} / {{ $rateLimitStatus['requests_per_hour'] }}
</div>
<div class="text-xs text-green-700">{{ $rateLimitStatus['hour_remaining'] }} remaining</div>
</div>
<div class="bg-purple-50 rounded-lg p-4">
<div class="text-sm text-purple-600 font-medium">Per Day</div>
<div class="text-2xl font-bold text-purple-900">
{{ $rateLimitStatus['current_day_count'] }} / {{ $rateLimitStatus['requests_per_day'] }}
</div>
<div class="text-xs text-purple-700">{{ $rateLimitStatus['day_remaining'] }} remaining</div>
</div>
</div>
@if($rateLimitStatus['is_rate_limited'])
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
⚠️ User is currently rate limited until {{ $rateLimitStatus['rate_limit_expires_at']->format('H:i:s') }}
</div>
@endif
<!-- Edit Rate Limit Form -->
<form action="{{ route('admin.users.rate-limit.update', $user) }}" method="POST" class="space-y-4">
@csrf
@method('PUT')
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="requests_per_minute" class="block text-sm font-medium text-gray-700 mb-2">
Requests Per Minute
</label>
<input
type="number"
name="requests_per_minute"
id="requests_per_minute"
value="{{ $rateLimitStatus['requests_per_minute'] }}"
min="0"
class="w-full rounded-md border-gray-300"
>
</div>
<div>
<label for="requests_per_hour" class="block text-sm font-medium text-gray-700 mb-2">
Requests Per Hour
</label>
<input
type="number"
name="requests_per_hour"
id="requests_per_hour"
value="{{ $rateLimitStatus['requests_per_hour'] }}"
min="0"
class="w-full rounded-md border-gray-300"
>
</div>
<div>
<label for="requests_per_day" class="block text-sm font-medium text-gray-700 mb-2">
Requests Per Day
</label>
<input
type="number"
name="requests_per_day"
id="requests_per_day"
value="{{ $rateLimitStatus['requests_per_day'] }}"
min="0"
class="w-full rounded-md border-gray-300"
>
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Update Rate Limits
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</x-app-layout>

View File

@@ -0,0 +1,129 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('User Management') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<!-- Search Filter -->
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg mb-6">
<div class="p-6">
<form method="GET" action="{{ route('admin.users.index') }}" class="space-y-4">
<div class="flex space-x-4">
<div class="flex-1">
<input
type="text"
name="search"
value="{{ request('search') }}"
placeholder="Search by name or email"
class="w-full rounded-md border-gray-300"
>
</div>
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Search
</button>
<a href="{{ route('admin.users.index') }}" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
Reset
</a>
</div>
</form>
</div>
</div>
<!-- Users Table -->
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Total Requests
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Monthly Budget
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Budget Status
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($users as $user)
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ $user->name }}</div>
<div class="text-sm text-gray-500">{{ $user->email }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ number_format($user->llm_requests_count) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
@if($user->budget)
${{ number_format($user->budget->monthly_limit, 2) }}
@else
<span class="text-gray-400">Not set</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if($user->budget)
@php
$percentage = $user->budget->monthly_limit > 0
? ($user->budget->current_month_spending / $user->budget->monthly_limit) * 100
: 0;
@endphp
@if($user->budget->is_budget_exceeded)
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
Exceeded
</span>
@elseif($percentage >= 80)
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
{{ round($percentage) }}%
</span>
@else
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
{{ round($percentage) }}%
</span>
@endif
@else
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
No budget
</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<a href="{{ route('admin.users.budget.show', $user) }}" class="text-indigo-600 hover:text-indigo-900">
Manage Budget
</a>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-6 py-4 text-center text-gray-500">
No users found.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="mt-4">
{{ $users->links() }}
</div>
</div>
</div>
</div>
</div>
</x-app-layout>

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\ChatCompletionController;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "api" middleware group. Make something great!
|
*/
Route::middleware('auth:sanctum')->group(function () {
// Chat Completion Endpoint
Route::post('/chat/completions', [ChatCompletionController::class, 'create'])
->middleware(['checkbudget', 'checkratelimit']);
// User info endpoint
Route::get('/user', function (Request $request) {
return $request->user();
});
});

View File

@@ -0,0 +1,144 @@
<?php
namespace Tests\Unit\Services;
use Tests\TestCase;
use App\Services\LLM\Providers\AnthropicProvider;
use App\Models\ModelPricing;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Foundation\Testing\RefreshDatabase;
class AnthropicProviderTest extends TestCase
{
use RefreshDatabase;
private AnthropicProvider $provider;
protected function setUp(): void
{
parent::setUp();
$this->provider = new AnthropicProvider('test-api-key');
}
public function test_builds_request_correctly_with_system_message(): void
{
$messages = [
['role' => 'system', 'content' => 'You are a helpful assistant'],
['role' => 'user', 'content' => 'Hello']
];
$options = [
'model' => 'claude-sonnet-4',
'temperature' => 0.7,
'max_tokens' => 2000
];
$reflection = new \ReflectionClass($this->provider);
$method = $reflection->getMethod('buildRequest');
$method->setAccessible(true);
$result = $method->invoke($this->provider, $messages, $options);
$this->assertEquals('claude-sonnet-4', $result['model']);
$this->assertEquals(0.7, $result['temperature']);
$this->assertEquals(2000, $result['max_tokens']);
$this->assertEquals('You are a helpful assistant', $result['system']);
$this->assertCount(1, $result['messages']); // System message extracted
$this->assertEquals('user', $result['messages'][0]['role']);
}
public function test_normalizes_response_correctly(): void
{
$rawResponse = [
'id' => 'msg_123',
'model' => 'claude-sonnet-4',
'content' => [
[
'type' => 'text',
'text' => 'Hello! How can I assist you today?'
]
],
'role' => 'assistant',
'stop_reason' => 'end_turn',
'usage' => [
'input_tokens' => 15,
'output_tokens' => 25
]
];
$normalized = $this->provider->normalizeResponse($rawResponse);
$this->assertEquals('msg_123', $normalized['id']);
$this->assertEquals('claude-sonnet-4', $normalized['model']);
$this->assertEquals('Hello! How can I assist you today?', $normalized['content']);
$this->assertEquals('assistant', $normalized['role']);
$this->assertEquals('end_turn', $normalized['finish_reason']);
$this->assertEquals(15, $normalized['usage']['prompt_tokens']);
$this->assertEquals(25, $normalized['usage']['completion_tokens']);
$this->assertEquals(40, $normalized['usage']['total_tokens']);
}
public function test_calculates_cost_correctly(): void
{
// Create pricing in database
ModelPricing::create([
'provider' => 'anthropic',
'model' => 'claude-sonnet-4',
'input_price_per_million' => 3.00,
'output_price_per_million' => 15.00,
'is_active' => true,
'effective_from' => now()
]);
Cache::flush();
$cost = $this->provider->calculateCost(1000, 500, 'claude-sonnet-4');
// Expected: (1000/1M * 3.00) + (500/1M * 15.00) = 0.003 + 0.0075 = 0.0105
$this->assertEquals(0.0105, $cost);
}
public function test_handles_api_errors(): void
{
Http::fake([
'https://api.anthropic.com/*' => Http::response(['error' => 'Invalid API key'], 401)
]);
$this->expectException(\App\Exceptions\ProviderException::class);
$this->expectExceptionMessage('Invalid API key');
$this->provider->chatCompletion([
['role' => 'user', 'content' => 'test']
]);
}
public function test_get_supported_models(): void
{
$models = $this->provider->getSupportedModels();
$this->assertIsArray($models);
$this->assertContains('claude-opus-4', $models);
$this->assertContains('claude-sonnet-4', $models);
$this->assertContains('claude-haiku-4', $models);
}
public function test_handles_multiple_content_blocks(): void
{
$rawResponse = [
'id' => 'msg_456',
'model' => 'claude-sonnet-4',
'content' => [
['type' => 'text', 'text' => 'First part. '],
['type' => 'text', 'text' => 'Second part.']
],
'role' => 'assistant',
'stop_reason' => 'end_turn',
'usage' => ['input_tokens' => 10, 'output_tokens' => 20]
];
$normalized = $this->provider->normalizeResponse($rawResponse);
$this->assertEquals('First part. Second part.', $normalized['content']);
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace Tests\Unit\Services;
use Tests\TestCase;
use App\Services\LLM\CostCalculator;
use App\Models\ModelPricing;
use Illuminate\Support\Facades\Cache;
use Illuminate\Foundation\Testing\RefreshDatabase;
class CostCalculatorTest extends TestCase
{
use RefreshDatabase;
private CostCalculator $calculator;
protected function setUp(): void
{
parent::setUp();
$this->calculator = new CostCalculator();
}
public function test_calculates_cost_correctly(): void
{
ModelPricing::create([
'provider' => 'openai',
'model' => 'gpt-4o-mini',
'input_price_per_million' => 0.15,
'output_price_per_million' => 0.60,
'is_active' => true,
'effective_from' => now(),
]);
Cache::flush();
$costs = $this->calculator->calculate('openai', 'gpt-4o-mini', 1000, 500);
// (1000/1M * 0.15) + (500/1M * 0.60) = 0.00015 + 0.0003 = 0.00045
$this->assertEquals(0.00015, $costs['prompt_cost']);
$this->assertEquals(0.0003, $costs['completion_cost']);
$this->assertEquals(0.00045, $costs['total_cost']);
}
public function test_returns_zero_cost_for_unknown_model(): void
{
Cache::flush();
$costs = $this->calculator->calculate('unknown', 'unknown-model', 1000, 500);
$this->assertEquals(0.0, $costs['prompt_cost']);
$this->assertEquals(0.0, $costs['completion_cost']);
$this->assertEquals(0.0, $costs['total_cost']);
}
public function test_uses_cache_for_pricing(): void
{
ModelPricing::create([
'provider' => 'anthropic',
'model' => 'claude-sonnet-4',
'input_price_per_million' => 3.00,
'output_price_per_million' => 15.00,
'is_active' => true,
'effective_from' => now(),
]);
Cache::flush();
// First call - should query database
$costs1 = $this->calculator->calculate('anthropic', 'claude-sonnet-4', 1000, 500);
// Second call - should use cache
$costs2 = $this->calculator->calculate('anthropic', 'claude-sonnet-4', 1000, 500);
$this->assertEquals($costs1, $costs2);
$this->assertTrue(Cache::has('pricing:anthropic:claude-sonnet-4'));
}
public function test_estimate_cost(): void
{
ModelPricing::create([
'provider' => 'openai',
'model' => 'gpt-4o',
'input_price_per_million' => 2.50,
'output_price_per_million' => 10.00,
'is_active' => true,
'effective_from' => now(),
]);
Cache::flush();
$estimatedCost = $this->calculator->estimateCost('openai', 'gpt-4o', 2000, 1000);
// (2000/1M * 2.50) + (1000/1M * 10.00) = 0.005 + 0.01 = 0.015
$this->assertEquals(0.015, $estimatedCost);
}
public function test_clear_cache(): void
{
Cache::put('pricing:test:model', 'test_data', 3600);
$this->calculator->clearCache('test', 'model');
$this->assertFalse(Cache::has('pricing:test:model'));
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace Tests\Unit\Services;
use Tests\TestCase;
use App\Services\LLM\Providers\DeepSeekProvider;
use App\Models\ModelPricing;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Foundation\Testing\RefreshDatabase;
class DeepSeekProviderTest extends TestCase
{
use RefreshDatabase;
private DeepSeekProvider $provider;
protected function setUp(): void
{
parent::setUp();
$this->provider = new DeepSeekProvider('test-api-key');
}
public function test_builds_request_correctly(): void
{
$messages = [
['role' => 'user', 'content' => 'Write a function']
];
$options = [
'model' => 'deepseek-coder',
'temperature' => 0.5,
'max_tokens' => 1500
];
$reflection = new \ReflectionClass($this->provider);
$method = $reflection->getMethod('buildRequest');
$method->setAccessible(true);
$result = $method->invoke($this->provider, $messages, $options);
$this->assertEquals('deepseek-coder', $result['model']);
$this->assertEquals(0.5, $result['temperature']);
$this->assertEquals(1500, $result['max_tokens']);
$this->assertEquals($messages, $result['messages']);
$this->assertFalse($result['stream']);
}
public function test_normalizes_response_correctly(): void
{
$rawResponse = [
'id' => 'deepseek-123',
'model' => 'deepseek-coder',
'choices' => [
[
'message' => [
'role' => 'assistant',
'content' => 'def hello_world():\n print("Hello, World!")'
],
'finish_reason' => 'stop'
]
],
'usage' => [
'prompt_tokens' => 8,
'completion_tokens' => 22,
'total_tokens' => 30
]
];
$normalized = $this->provider->normalizeResponse($rawResponse);
$this->assertEquals('deepseek-123', $normalized['id']);
$this->assertEquals('deepseek-coder', $normalized['model']);
$this->assertStringContainsString('def hello_world()', $normalized['content']);
$this->assertEquals('assistant', $normalized['role']);
$this->assertEquals('stop', $normalized['finish_reason']);
$this->assertEquals(8, $normalized['usage']['prompt_tokens']);
$this->assertEquals(22, $normalized['usage']['completion_tokens']);
$this->assertEquals(30, $normalized['usage']['total_tokens']);
}
public function test_calculates_cost_correctly(): void
{
ModelPricing::updateOrCreate(
[
'provider' => 'deepseek',
'model' => 'deepseek-chat',
'effective_from' => now()->toDateString(),
],
[
'input_price_per_million' => 0.14,
'output_price_per_million' => 0.28,
'is_active' => true,
]
);
Cache::flush();
$cost = $this->provider->calculateCost(1000, 500, 'deepseek-chat');
// Expected: (1000/1M * 0.14) + (500/1M * 0.28) = 0.00014 + 0.00014 = 0.00028
$this->assertEquals(0.00028, $cost);
}
public function test_handles_api_errors(): void
{
Http::fake([
'https://api.deepseek.com/*' => Http::response(['error' => 'Invalid API key'], 401)
]);
$this->expectException(\App\Exceptions\ProviderException::class);
$this->expectExceptionMessage('Invalid API key');
$this->provider->chatCompletion([
['role' => 'user', 'content' => 'test']
]);
}
public function test_get_supported_models(): void
{
$models = $this->provider->getSupportedModels();
$this->assertIsArray($models);
$this->assertContains('deepseek-chat', $models);
$this->assertContains('deepseek-coder', $models);
$this->assertContains('deepseek-reasoner', $models);
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace Tests\Unit\Services;
use Tests\TestCase;
use App\Services\LLM\Providers\GeminiProvider;
use App\Models\ModelPricing;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Foundation\Testing\RefreshDatabase;
class GeminiProviderTest extends TestCase
{
use RefreshDatabase;
private GeminiProvider $provider;
protected function setUp(): void
{
parent::setUp();
$this->provider = new GeminiProvider('test-api-key');
}
public function test_builds_request_correctly(): void
{
$messages = [
['role' => 'user', 'content' => 'Hello, Gemini!']
];
$options = [
'model' => 'gemini-pro',
'temperature' => 0.9,
'max_tokens' => 2000
];
$reflection = new \ReflectionClass($this->provider);
$method = $reflection->getMethod('buildRequest');
$method->setAccessible(true);
$result = $method->invoke($this->provider, $messages, $options);
$this->assertArrayHasKey('contents', $result);
$this->assertCount(1, $result['contents']);
$this->assertEquals('user', $result['contents'][0]['role']);
$this->assertEquals('Hello, Gemini!', $result['contents'][0]['parts'][0]['text']);
$this->assertArrayHasKey('generationConfig', $result);
$this->assertEquals(0.9, $result['generationConfig']['temperature']);
$this->assertEquals(2000, $result['generationConfig']['maxOutputTokens']);
}
public function test_converts_system_messages_to_user(): void
{
$messages = [
['role' => 'system', 'content' => 'You are helpful'],
['role' => 'user', 'content' => 'Hello']
];
$reflection = new \ReflectionClass($this->provider);
$method = $reflection->getMethod('buildRequest');
$method->setAccessible(true);
$result = $method->invoke($this->provider, $messages, []);
$this->assertEquals('user', $result['contents'][0]['role']);
$this->assertEquals('user', $result['contents'][1]['role']);
}
public function test_normalizes_response_correctly(): void
{
$rawResponse = [
'candidates' => [
[
'content' => [
'parts' => [
['text' => 'Hello! How can I help you today?']
]
],
'finishReason' => 'STOP'
]
],
'usageMetadata' => [
'promptTokenCount' => 8,
'candidatesTokenCount' => 15,
'totalTokenCount' => 23
],
'modelVersion' => 'gemini-pro'
];
$normalized = $this->provider->normalizeResponse($rawResponse);
$this->assertEquals('gemini-pro', $normalized['model']);
$this->assertEquals('Hello! How can I help you today?', $normalized['content']);
$this->assertEquals('assistant', $normalized['role']);
$this->assertEquals('STOP', $normalized['finish_reason']);
$this->assertEquals(8, $normalized['usage']['prompt_tokens']);
$this->assertEquals(15, $normalized['usage']['completion_tokens']);
$this->assertEquals(23, $normalized['usage']['total_tokens']);
}
public function test_calculates_cost_correctly(): void
{
ModelPricing::updateOrCreate(
[
'provider' => 'gemini',
'model' => 'gemini-pro',
'effective_from' => now()->toDateString(),
],
[
'input_price_per_million' => 0.50,
'output_price_per_million' => 1.50,
'is_active' => true,
]
);
Cache::flush();
$cost = $this->provider->calculateCost(1000, 500, 'gemini-pro');
// Expected: (1000/1M * 0.50) + (500/1M * 1.50) = 0.0005 + 0.00075 = 0.00125
$this->assertEquals(0.00125, $cost);
}
public function test_handles_api_errors(): void
{
Http::fake([
'https://generativelanguage.googleapis.com/*' => Http::response(['error' => 'Invalid API key'], 401)
]);
$this->expectException(\App\Exceptions\ProviderException::class);
$this->expectExceptionMessage('Invalid API key');
$this->provider->chatCompletion([
['role' => 'user', 'content' => 'test']
]);
}
public function test_get_supported_models(): void
{
$models = $this->provider->getSupportedModels();
$this->assertIsArray($models);
$this->assertContains('gemini-pro', $models);
$this->assertContains('gemini-1.5-pro', $models);
$this->assertContains('gemini-1.5-flash', $models);
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace Tests\Unit\Services;
use Tests\TestCase;
use App\Services\LLM\Providers\MistralProvider;
use App\Models\ModelPricing;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Foundation\Testing\RefreshDatabase;
class MistralProviderTest extends TestCase
{
use RefreshDatabase;
private MistralProvider $provider;
protected function setUp(): void
{
parent::setUp();
$this->provider = new MistralProvider('test-api-key');
}
public function test_builds_request_correctly(): void
{
$messages = [
['role' => 'user', 'content' => 'Hello']
];
$options = [
'model' => 'mistral-small-latest',
'temperature' => 0.8,
'max_tokens' => 1000
];
$reflection = new \ReflectionClass($this->provider);
$method = $reflection->getMethod('buildRequest');
$method->setAccessible(true);
$result = $method->invoke($this->provider, $messages, $options);
$this->assertEquals('mistral-small-latest', $result['model']);
$this->assertEquals(0.8, $result['temperature']);
$this->assertEquals(1000, $result['max_tokens']);
$this->assertEquals($messages, $result['messages']);
$this->assertArrayNotHasKey('stream', $result); // stream=false is filtered out
}
public function test_normalizes_response_correctly(): void
{
$rawResponse = [
'id' => 'cmpl-123',
'model' => 'mistral-small-latest',
'choices' => [
[
'message' => [
'role' => 'assistant',
'content' => 'Bonjour! Comment puis-je vous aider?'
],
'finish_reason' => 'stop'
]
],
'usage' => [
'prompt_tokens' => 12,
'completion_tokens' => 18,
'total_tokens' => 30
]
];
$normalized = $this->provider->normalizeResponse($rawResponse);
$this->assertEquals('cmpl-123', $normalized['id']);
$this->assertEquals('mistral-small-latest', $normalized['model']);
$this->assertEquals('Bonjour! Comment puis-je vous aider?', $normalized['content']);
$this->assertEquals('assistant', $normalized['role']);
$this->assertEquals('stop', $normalized['finish_reason']);
$this->assertEquals(12, $normalized['usage']['prompt_tokens']);
$this->assertEquals(18, $normalized['usage']['completion_tokens']);
$this->assertEquals(30, $normalized['usage']['total_tokens']);
}
public function test_calculates_cost_correctly(): void
{
ModelPricing::updateOrCreate(
[
'provider' => 'mistral',
'model' => 'mistral-small-latest',
'effective_from' => now()->toDateString(),
],
[
'input_price_per_million' => 0.20,
'output_price_per_million' => 0.60,
'is_active' => true,
]
);
Cache::flush();
$cost = $this->provider->calculateCost(1000, 500, 'mistral-small-latest');
// Expected: (1000/1M * 0.20) + (500/1M * 0.60) = 0.0002 + 0.0003 = 0.0005
$this->assertEquals(0.0005, $cost);
}
public function test_handles_api_errors(): void
{
Http::fake([
'https://api.mistral.ai/*' => Http::response(['error' => 'Invalid API key'], 401)
]);
$this->expectException(\App\Exceptions\ProviderException::class);
$this->expectExceptionMessage('Invalid API key');
$this->provider->chatCompletion([
['role' => 'user', 'content' => 'test']
]);
}
public function test_get_supported_models(): void
{
$models = $this->provider->getSupportedModels();
$this->assertIsArray($models);
$this->assertContains('mistral-large-latest', $models);
$this->assertContains('mistral-small-latest', $models);
$this->assertContains('open-mixtral-8x7b', $models);
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace Tests\Unit\Services;
use Tests\TestCase;
use App\Services\LLM\Providers\OpenAIProvider;
use App\Models\ModelPricing;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Foundation\Testing\RefreshDatabase;
class OpenAIProviderTest extends TestCase
{
use RefreshDatabase;
private OpenAIProvider $provider;
protected function setUp(): void
{
parent::setUp();
$this->provider = new OpenAIProvider('test-api-key');
}
public function test_builds_request_correctly(): void
{
$messages = [
['role' => 'user', 'content' => 'Hello']
];
$options = [
'model' => 'gpt-4o-mini',
'temperature' => 0.8,
'max_tokens' => 1000
];
$reflection = new \ReflectionClass($this->provider);
$method = $reflection->getMethod('buildRequest');
$method->setAccessible(true);
$result = $method->invoke($this->provider, $messages, $options);
$this->assertEquals('gpt-4o-mini', $result['model']);
$this->assertEquals(0.8, $result['temperature']);
$this->assertEquals(1000, $result['max_tokens']);
$this->assertEquals($messages, $result['messages']);
$this->assertFalse($result['stream']);
}
public function test_normalizes_response_correctly(): void
{
$rawResponse = [
'id' => 'chatcmpl-123',
'model' => 'gpt-4o-mini',
'choices' => [
[
'message' => [
'role' => 'assistant',
'content' => 'Hello! How can I help you?'
],
'finish_reason' => 'stop'
]
],
'usage' => [
'prompt_tokens' => 10,
'completion_tokens' => 20,
'total_tokens' => 30
]
];
$normalized = $this->provider->normalizeResponse($rawResponse);
$this->assertEquals('chatcmpl-123', $normalized['id']);
$this->assertEquals('gpt-4o-mini', $normalized['model']);
$this->assertEquals('Hello! How can I help you?', $normalized['content']);
$this->assertEquals('assistant', $normalized['role']);
$this->assertEquals('stop', $normalized['finish_reason']);
$this->assertEquals(10, $normalized['usage']['prompt_tokens']);
$this->assertEquals(20, $normalized['usage']['completion_tokens']);
$this->assertEquals(30, $normalized['usage']['total_tokens']);
}
public function test_calculates_cost_correctly(): void
{
// Create pricing in database
ModelPricing::create([
'provider' => 'openai',
'model' => 'gpt-4o-mini',
'input_price_per_million' => 0.15,
'output_price_per_million' => 0.60,
'is_active' => true,
'effective_from' => now()
]);
Cache::flush();
$cost = $this->provider->calculateCost(1000, 500, 'gpt-4o-mini');
// Expected: (1000/1M * 0.15) + (500/1M * 0.60) = 0.00015 + 0.0003 = 0.00045
$this->assertEquals(0.00045, $cost);
}
public function test_handles_api_errors(): void
{
Http::fake([
'https://api.openai.com/*' => Http::response(['error' => 'Invalid API key'], 401)
]);
$this->expectException(\App\Exceptions\ProviderException::class);
$this->expectExceptionMessage('Invalid API key');
$this->provider->chatCompletion([
['role' => 'user', 'content' => 'test']
]);
}
public function test_retries_on_server_error(): void
{
Http::fake([
'https://api.openai.com/*' => Http::sequence()
->push(['error' => 'Server error'], 500)
->push(['error' => 'Server error'], 500)
->push([
'id' => 'test-123',
'model' => 'gpt-4o-mini',
'choices' => [[
'message' => ['content' => 'Success', 'role' => 'assistant'],
'finish_reason' => 'stop'
]],
'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 5, 'total_tokens' => 15]
], 200)
]);
$result = $this->provider->chatCompletion([
['role' => 'user', 'content' => 'test']
]);
$this->assertArrayHasKey('id', $result);
$this->assertEquals('test-123', $result['id']);
}
public function test_get_supported_models(): void
{
$models = $this->provider->getSupportedModels();
$this->assertIsArray($models);
$this->assertContains('gpt-4o', $models);
$this->assertContains('gpt-4o-mini', $models);
$this->assertContains('gpt-3.5-turbo', $models);
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Tests\Unit\Services;
use Tests\TestCase;
use App\Services\LLM\ProviderFactory;
use App\Services\LLM\Providers\{
OpenAIProvider,
AnthropicProvider,
MistralProvider,
GeminiProvider,
DeepSeekProvider
};
class ProviderFactoryTest extends TestCase
{
public function test_creates_openai_provider(): void
{
$provider = ProviderFactory::create('openai', 'test-key');
$this->assertInstanceOf(OpenAIProvider::class, $provider);
}
public function test_creates_anthropic_provider(): void
{
$provider = ProviderFactory::create('anthropic', 'test-key');
$this->assertInstanceOf(AnthropicProvider::class, $provider);
}
public function test_creates_mistral_provider(): void
{
$provider = ProviderFactory::create('mistral', 'test-key');
$this->assertInstanceOf(MistralProvider::class, $provider);
}
public function test_creates_gemini_provider(): void
{
$provider = ProviderFactory::create('gemini', 'test-key');
$this->assertInstanceOf(GeminiProvider::class, $provider);
}
public function test_creates_deepseek_provider(): void
{
$provider = ProviderFactory::create('deepseek', 'test-key');
$this->assertInstanceOf(DeepSeekProvider::class, $provider);
}
public function test_throws_exception_for_unknown_provider(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Unknown provider: unknown');
ProviderFactory::create('unknown', 'test-key');
}
public function test_is_case_insensitive(): void
{
$provider1 = ProviderFactory::create('OpenAI', 'test-key');
$provider2 = ProviderFactory::create('ANTHROPIC', 'test-key');
$this->assertInstanceOf(OpenAIProvider::class, $provider1);
$this->assertInstanceOf(AnthropicProvider::class, $provider2);
}
public function test_get_supported_providers(): void
{
$providers = ProviderFactory::getSupportedProviders();
$this->assertIsArray($providers);
$this->assertContains('openai', $providers);
$this->assertContains('anthropic', $providers);
$this->assertContains('mistral', $providers);
$this->assertContains('gemini', $providers);
$this->assertContains('deepseek', $providers);
$this->assertCount(5, $providers);
}
public function test_is_supported(): void
{
$this->assertTrue(ProviderFactory::isSupported('openai'));
$this->assertTrue(ProviderFactory::isSupported('anthropic'));
$this->assertTrue(ProviderFactory::isSupported('mistral'));
$this->assertTrue(ProviderFactory::isSupported('gemini'));
$this->assertTrue(ProviderFactory::isSupported('deepseek'));
$this->assertFalse(ProviderFactory::isSupported('unknown'));
}
public function test_is_supported_case_insensitive(): void
{
$this->assertTrue(ProviderFactory::isSupported('OpenAI'));
$this->assertTrue(ProviderFactory::isSupported('ANTHROPIC'));
}
}