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