Rename project from any-llm to laravel-llm

- Remove old any-llm related files (Dockerfile, config.yml, web/, setup-laravel.sh)
- Update README.md with new Laravel LLM Gateway documentation
- Keep docker-compose.yml with laravel-llm container names
- Clean project structure for Laravel-only implementation
This commit is contained in:
wtrinkl
2025-11-18 22:05:05 +01:00
parent b1363aeab9
commit bef36c7ca2
33 changed files with 1341 additions and 2930 deletions

View File

@@ -70,52 +70,39 @@ class ApiKeyController extends Controller
{
$validated = $request->validate([
'key_name' => 'required|string|max:255',
'user_id' => 'required|string|exists:users,user_id',
'user_id' => 'required|string|exists:gateway_users,user_id',
'expires_at' => 'nullable|date|after:now',
'metadata' => 'nullable|json',
]);
try {
// Get master key from config
$masterKey = env('GATEWAY_MASTER_KEY');
if (!$masterKey) {
return back()->with('error', 'Gateway Master Key not configured');
// Generate a unique API token
$token = 'llmg_' . Str::random(48);
// Parse metadata if provided
$metadata = null;
if (!empty($validated['metadata'])) {
$metadata = json_decode($validated['metadata'], true);
if (json_last_error() !== JSON_ERROR_NONE) {
return back()->with('error', 'Invalid JSON in metadata field');
}
}
// Prepare request payload
$payload = [
// Create API key directly in database
$apiKey = ApiKey::create([
'token' => $token,
'user_id' => $validated['user_id'],
'key_name' => $validated['key_name'],
];
'key_alias' => $validated['key_name'], // Use key_name as alias
'expires' => $validated['expires_at'] ?? null,
'metadata' => $metadata,
'permissions' => [], // Default empty permissions
'models' => [], // Default empty models
]);
// Add optional fields only if they have values
if (!empty($validated['expires_at'])) {
$payload['expires_at'] = $validated['expires_at'];
}
if (!empty($validated['metadata'])) {
$payload['metadata'] = json_decode($validated['metadata'], true) ?: new \stdClass();
}
// Create Virtual Key via Any-LLM Gateway API
$response = Http::withHeaders([
'X-AnyLLM-Key' => 'Bearer ' . $masterKey,
'Content-Type' => 'application/json',
])->post(env('GATEWAY_API_URL', 'http://gateway:8000') . '/v1/keys', $payload);
if (!$response->successful()) {
Log::error('Failed to create API key', [
'status' => $response->status(),
'body' => $response->body()
]);
return back()->with('error', 'Failed to create API key: ' . $response->body());
}
$data = $response->json();
// The actual key is only available once - store it in session for display
session()->flash('new_api_key', $data['key'] ?? null);
session()->flash('new_api_key_id', $data['id'] ?? null);
// Store the token in session for one-time display
session()->flash('new_api_key', $token);
session()->flash('new_api_key_id', $apiKey->token);
return redirect()->route('api-keys.index')
->with('success', 'API Key created successfully! Make sure to copy it now - it won\'t be shown again.');
@@ -160,26 +147,8 @@ class ApiKeyController extends Controller
try {
$apiKey = ApiKey::findOrFail($id);
// Get master key from config
$masterKey = env('GATEWAY_MASTER_KEY');
if (!$masterKey) {
return back()->with('error', 'Gateway Master Key not configured');
}
// Revoke via Any-LLM Gateway API
$response = Http::withHeaders([
'X-AnyLLM-Key' => 'Bearer ' . $masterKey,
'Content-Type' => 'application/json',
])->delete(env('GATEWAY_API_URL', 'http://gateway:8000') . '/v1/keys/' . $id);
if (!$response->successful()) {
Log::error('Failed to revoke API key', [
'key_id' => $id,
'status' => $response->status(),
'body' => $response->body()
]);
return back()->with('error', 'Failed to revoke API key: ' . $response->body());
}
// Delete the API key from database
$apiKey->delete();
return redirect()->route('api-keys.index')
->with('success', 'API Key revoked successfully');

View File

@@ -41,19 +41,35 @@ class BudgetController extends Controller
'custom_duration_days' => 'nullable|integer|min:1|required_if:budget_type,custom',
]);
// Calculate budget_duration_sec based on type
$duration = match($validated['budget_type']) {
'daily' => 86400, // 1 day
'weekly' => 604800, // 7 days
'monthly' => 2592000, // 30 days
'custom' => ($validated['custom_duration_days'] ?? 1) * 86400,
'unlimited' => null,
};
// Set monthly and daily limits based on budget type
$monthlyLimit = null;
$dailyLimit = null;
switch($validated['budget_type']) {
case 'daily':
$dailyLimit = $validated['max_budget'];
break;
case 'weekly':
$dailyLimit = $validated['max_budget'] / 7;
break;
case 'monthly':
$monthlyLimit = $validated['max_budget'];
$dailyLimit = $validated['max_budget'] / 30;
break;
case 'custom':
$days = $validated['custom_duration_days'] ?? 1;
$dailyLimit = $validated['max_budget'] / $days;
break;
case 'unlimited':
// No limits
break;
}
$budget = Budget::create([
'budget_id' => 'budget-' . Str::uuid(),
'max_budget' => $validated['max_budget'],
'budget_duration_sec' => $duration,
'name' => $validated['budget_name'],
'monthly_limit' => $monthlyLimit,
'daily_limit' => $dailyLimit,
]);
return redirect()
@@ -106,23 +122,40 @@ class BudgetController extends Controller
$budget = Budget::findOrFail($id);
$validated = $request->validate([
'budget_name' => 'required|string|max:255',
'max_budget' => 'required|numeric|min:0',
'budget_type' => 'required|in:daily,weekly,monthly,custom,unlimited',
'custom_duration_days' => 'nullable|integer|min:1|required_if:budget_type,custom',
]);
// Calculate budget_duration_sec based on type
$duration = match($validated['budget_type']) {
'daily' => 86400,
'weekly' => 604800,
'monthly' => 2592000,
'custom' => ($validated['custom_duration_days'] ?? 1) * 86400,
'unlimited' => null,
};
// Set monthly and daily limits based on budget type
$monthlyLimit = null;
$dailyLimit = null;
switch($validated['budget_type']) {
case 'daily':
$dailyLimit = $validated['max_budget'];
break;
case 'weekly':
$dailyLimit = $validated['max_budget'] / 7;
break;
case 'monthly':
$monthlyLimit = $validated['max_budget'];
$dailyLimit = $validated['max_budget'] / 30;
break;
case 'custom':
$days = $validated['custom_duration_days'] ?? 1;
$dailyLimit = $validated['max_budget'] / $days;
break;
case 'unlimited':
// No limits
break;
}
$budget->update([
'max_budget' => $validated['max_budget'],
'budget_duration_sec' => $duration,
'name' => $validated['budget_name'],
'monthly_limit' => $monthlyLimit,
'daily_limit' => $dailyLimit,
]);
return redirect()

View File

@@ -21,13 +21,28 @@ class DashboardController extends Controller
$topUsers = $this->statsService->getTopUsers(5);
$providerStats = $this->statsService->getUsageByProvider(30);
$modelStats = $this->statsService->getUsageByModel(30);
$costTrends = $this->statsService->getCostTrends(30);
$errorStats = $this->statsService->getErrorStats(30);
return view('dashboard', compact(
'stats',
'dailyUsage',
'topUsers',
'providerStats',
'modelStats'
'modelStats',
'costTrends',
'errorStats'
));
}
/**
* Get real-time stats via AJAX
*/
public function realtimeStats()
{
return response()->json([
'stats' => $this->statsService->getDashboardStats(),
'timestamp' => now()->toIso8601String(),
]);
}
}

View File

@@ -13,7 +13,8 @@ class ModelPricingController extends Controller
*/
public function index()
{
$modelPricing = ModelPricing::orderBy('model_key')
$modelPricing = ModelPricing::orderBy('provider')
->orderBy('model')
->paginate(20);
return view('model-pricing.index', compact('modelPricing'));
@@ -33,9 +34,16 @@ class ModelPricingController extends Controller
public function store(Request $request)
{
$validated = $request->validate([
'model_key' => 'required|string|max:255|unique:model_pricing,model_key',
'provider' => 'required|string|max:50',
'model' => 'required|string|max:100',
'input_price_per_million' => 'required|numeric|min:0',
'output_price_per_million' => 'required|numeric|min:0',
'context_window' => 'nullable|integer|min:0',
'max_output_tokens' => 'nullable|integer|min:0',
'is_active' => 'boolean',
'effective_from' => 'nullable|date',
'effective_until' => 'nullable|date',
'notes' => 'nullable|string',
]);
ModelPricing::create($validated);
@@ -48,36 +56,38 @@ class ModelPricingController extends Controller
/**
* Display the specified model pricing
*/
public function show(string $modelKey)
public function show(ModelPricing $modelPricing)
{
$model = ModelPricing::findOrFail($modelKey);
return view('model-pricing.show', compact('model'));
return view('model-pricing.show', compact('modelPricing'));
}
/**
* Show the form for editing the specified model pricing
*/
public function edit(string $modelKey)
public function edit(ModelPricing $modelPricing)
{
$model = ModelPricing::findOrFail($modelKey);
return view('model-pricing.edit', compact('model'));
return view('model-pricing.edit', compact('modelPricing'));
}
/**
* Update the specified model pricing
*/
public function update(Request $request, string $modelKey)
public function update(Request $request, ModelPricing $modelPricing)
{
$model = ModelPricing::findOrFail($modelKey);
$validated = $request->validate([
'provider' => 'required|string|max:50',
'model' => 'required|string|max:100',
'input_price_per_million' => 'required|numeric|min:0',
'output_price_per_million' => 'required|numeric|min:0',
'context_window' => 'nullable|integer|min:0',
'max_output_tokens' => 'nullable|integer|min:0',
'is_active' => 'boolean',
'effective_from' => 'nullable|date',
'effective_until' => 'nullable|date',
'notes' => 'nullable|string',
]);
$model->update($validated);
$modelPricing->update($validated);
return redirect()
->route('model-pricing.index')
@@ -87,10 +97,9 @@ class ModelPricingController extends Controller
/**
* Remove the specified model pricing
*/
public function destroy(string $modelKey)
public function destroy(ModelPricing $modelPricing)
{
$model = ModelPricing::findOrFail($modelKey);
$model->delete();
$modelPricing->delete();
return redirect()
->route('model-pricing.index')
@@ -102,7 +111,10 @@ class ModelPricingController extends Controller
*/
public function calculator()
{
$models = ModelPricing::orderBy('model_key')->get();
$models = ModelPricing::where('is_active', true)
->orderBy('provider')
->orderBy('model')
->get();
return view('model-pricing.calculator', compact('models'));
}
@@ -113,19 +125,20 @@ class ModelPricingController extends Controller
public function calculate(Request $request)
{
$validated = $request->validate([
'model_key' => 'required|exists:model_pricing,model_key',
'model_pricing_id' => 'required|exists:model_pricing,id',
'input_tokens' => 'required|integer|min:0',
'output_tokens' => 'required|integer|min:0',
]);
$model = ModelPricing::findOrFail($validated['model_key']);
$model = ModelPricing::findOrFail($validated['model_pricing_id']);
$cost = $model->calculateCost(
$validated['input_tokens'],
$validated['output_tokens']
);
return response()->json([
'model' => $model->model_key,
'provider' => $model->provider,
'model' => $model->model,
'input_tokens' => $validated['input_tokens'],
'output_tokens' => $validated['output_tokens'],
'total_tokens' => $validated['input_tokens'] + $validated['output_tokens'],
@@ -162,20 +175,23 @@ class ModelPricingController extends Controller
fgetcsv($handle);
while (($row = fgetcsv($handle)) !== false) {
if (count($row) < 3) {
if (count($row) < 4) {
continue; // Skip invalid rows
}
$modelKey = trim($row[0]);
$inputPrice = floatval($row[1]);
$outputPrice = floatval($row[2]);
$provider = trim($row[0]);
$model = trim($row[1]);
$inputPrice = floatval($row[2]);
$outputPrice = floatval($row[3]);
if (empty($modelKey) || $inputPrice < 0 || $outputPrice < 0) {
$errors[] = "Invalid data for model: {$modelKey}";
if (empty($provider) || empty($model) || $inputPrice < 0 || $outputPrice < 0) {
$errors[] = "Invalid data for model: {$provider}/{$model}";
continue;
}
$existing = ModelPricing::find($modelKey);
$existing = ModelPricing::where('provider', $provider)
->where('model', $model)
->first();
if ($existing) {
$existing->update([
@@ -185,7 +201,8 @@ class ModelPricingController extends Controller
$updated++;
} else {
ModelPricing::create([
'model_key' => $modelKey,
'provider' => $provider,
'model' => $model,
'input_price_per_million' => $inputPrice,
'output_price_per_million' => $outputPrice,
]);
@@ -205,4 +222,291 @@ class ModelPricingController extends Controller
->route('model-pricing.index')
->with('success', $message);
}
/**
* Get available models from a provider
*/
public function getProviderModels(string $provider)
{
try {
$models = [];
switch($provider) {
case 'openai':
$models = $this->getOpenAIModels();
break;
case 'anthropic':
$models = $this->getAnthropicModels();
break;
case 'deepseek':
$models = $this->getDeepSeekModels();
break;
case 'google':
$models = $this->getGeminiModels();
break;
case 'cohere':
return response()->json([
'success' => false,
'message' => 'Cohere does not provide a public models API. Please enter model names manually'
]);
case 'mistral':
$models = $this->getMistralModels();
break;
default:
return response()->json([
'success' => false,
'message' => 'Provider not supported for automatic model fetching'
]);
}
return response()->json([
'success' => true,
'models' => $models
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 500);
}
}
/**
* Fetch OpenAI models
*/
private function getOpenAIModels()
{
// Get API key from credentials
$credential = \App\Models\UserProviderCredential::where('provider', 'openai')
->where('is_active', true)
->first();
if (!$credential || !$credential->api_key) {
throw new \Exception('No active OpenAI credentials found. Please add credentials first.');
}
$response = \Illuminate\Support\Facades\Http::withHeaders([
'Authorization' => 'Bearer ' . $credential->api_key,
])->get('https://api.openai.com/v1/models');
if (!$response->successful()) {
throw new \Exception('Failed to fetch OpenAI models: ' . $response->body());
}
$data = $response->json();
$models = [];
foreach ($data['data'] as $model) {
if (isset($model['id'])) {
$models[] = [
'id' => $model['id'],
'name' => $model['id'],
'created' => $model['created'] ?? null,
];
}
}
// Sort by name
usort($models, fn($a, $b) => strcmp($a['name'], $b['name']));
return $models;
}
/**
* Fetch Anthropic models from API
*/
private function getAnthropicModels()
{
// Get API key from credentials
$credential = \App\Models\UserProviderCredential::where('provider', 'anthropic')
->where('is_active', true)
->first();
if (!$credential || !$credential->api_key) {
throw new \Exception('No active Anthropic credentials found. Please add credentials first.');
}
$response = \Illuminate\Support\Facades\Http::withHeaders([
'x-api-key' => $credential->api_key,
'anthropic-version' => '2023-06-01',
])->get('https://api.anthropic.com/v1/models');
if (!$response->successful()) {
throw new \Exception('Failed to fetch Anthropic models: ' . $response->body());
}
$data = $response->json();
$models = [];
foreach ($data['data'] as $model) {
if (isset($model['id'])) {
$models[] = [
'id' => $model['id'],
'name' => $model['display_name'] ?? $model['id'],
'created' => isset($model['created_at']) ? strtotime($model['created_at']) : null,
];
}
}
// Sort by name
usort($models, fn($a, $b) => strcmp($a['name'], $b['name']));
return $models;
}
/**
* Fetch DeepSeek models from API
*/
private function getDeepSeekModels()
{
// Get API key from credentials
$credential = \App\Models\UserProviderCredential::where('provider', 'deepseek')
->where('is_active', true)
->first();
if (!$credential || !$credential->api_key) {
throw new \Exception('No active DeepSeek credentials found. Please add credentials first.');
}
// DeepSeek uses OpenAI-compatible API
$response = \Illuminate\Support\Facades\Http::withHeaders([
'Authorization' => 'Bearer ' . $credential->api_key,
])->get('https://api.deepseek.com/models');
if (!$response->successful()) {
throw new \Exception('Failed to fetch DeepSeek models: ' . $response->body());
}
$data = $response->json();
$models = [];
foreach ($data['data'] as $model) {
if (isset($model['id'])) {
$models[] = [
'id' => $model['id'],
'name' => $model['id'],
'created' => $model['created'] ?? null,
];
}
}
// Sort by name
usort($models, fn($a, $b) => strcmp($a['name'], $b['name']));
return $models;
}
/**
* Fetch Google Gemini models from API
*/
private function getGeminiModels()
{
// Get API key from credentials
$credential = \App\Models\UserProviderCredential::where('provider', 'gemini')
->where('is_active', true)
->first();
if (!$credential || !$credential->api_key) {
throw new \Exception('No active Google Gemini credentials found. Please add credentials first.');
}
$models = [];
$nextPageToken = null;
// Fetch all pages
do {
$url = 'https://generativelanguage.googleapis.com/v1beta/models?key=' . $credential->api_key;
if ($nextPageToken) {
$url .= '&pageToken=' . $nextPageToken;
}
$response = \Illuminate\Support\Facades\Http::get($url);
if (!$response->successful()) {
throw new \Exception('Failed to fetch Gemini models: ' . $response->body());
}
$data = $response->json();
foreach ($data['models'] as $model) {
// Only include models that support generateContent
if (isset($model['name']) &&
isset($model['supportedGenerationMethods']) &&
in_array('generateContent', $model['supportedGenerationMethods'])) {
// Extract model ID from "models/gemini-xxx" format
$modelId = str_replace('models/', '', $model['name']);
$models[] = [
'id' => $modelId,
'name' => $model['displayName'] ?? $modelId,
'created' => null,
];
}
}
$nextPageToken = $data['nextPageToken'] ?? null;
} while ($nextPageToken);
// Sort by name
usort($models, fn($a, $b) => strcmp($a['name'], $b['name']));
return $models;
}
/**
* Fetch Mistral AI models from API
*/
private function getMistralModels()
{
// Get API key from credentials
$credential = \App\Models\UserProviderCredential::where('provider', 'mistral')
->where('is_active', true)
->first();
if (!$credential || !$credential->api_key) {
throw new \Exception('No active Mistral AI credentials found. Please add credentials first.');
}
$response = \Illuminate\Support\Facades\Http::withHeaders([
'Authorization' => 'Bearer ' . $credential->api_key,
])->get('https://api.mistral.ai/v1/models');
if (!$response->successful()) {
throw new \Exception('Failed to fetch Mistral models: ' . $response->body());
}
$data = $response->json();
$models = [];
$seenIds = []; // Track seen IDs to avoid duplicates (aliases)
foreach ($data['data'] as $model) {
// Skip deprecated models
if (isset($model['deprecation']) && $model['deprecation']) {
continue;
}
// Only include models that support chat completion
if (isset($model['id']) &&
isset($model['capabilities']['completion_chat']) &&
$model['capabilities']['completion_chat'] === true &&
!isset($seenIds[$model['id']])) {
$seenIds[$model['id']] = true;
$models[] = [
'id' => $model['id'],
'name' => $model['name'] ?? $model['id'],
'created' => $model['created'] ?? null,
];
}
}
// Sort by name
usort($models, fn($a, $b) => strcmp($a['name'], $b['name']));
return $models;
}
}

View File

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

View File

@@ -2,34 +2,73 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ApiKey extends Model
{
protected $primaryKey = 'id';
use HasFactory;
protected $table = 'api_keys';
protected $primaryKey = 'token';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'id',
'key_hash',
'key_name',
'token',
'user_id',
'last_used_at',
'expires_at',
'is_active',
'key_alias',
'key_name',
'permissions',
'models',
'metadata',
'expires',
];
protected function casts(): array
protected $casts = [
'permissions' => 'array',
'models' => 'array',
'metadata' => 'array',
'expires' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* Get masked version of the key
*/
public function getMaskedKeyAttribute(): string
{
return [
'is_active' => 'boolean',
'metadata' => 'array',
'created_at' => 'datetime',
'last_used_at' => 'datetime',
'expires_at' => 'datetime',
];
return substr($this->token, 0, 8) . '...' . substr($this->token, -4);
}
/**
* Check if key is active (not explicitly marked inactive)
*/
public function getIsActiveAttribute(): bool
{
// For now, consider all keys active unless explicitly deleted
return true;
}
/**
* Check if key is expired
*/
public function getIsExpiredAttribute(): bool
{
if (!$this->expires) {
return false;
}
return $this->expires->isPast();
}
/**
* Get last used at timestamp
*/
public function getLastUsedAtAttribute()
{
$latestLog = $this->usageLogs()->latest('timestamp')->first();
return $latestLog ? $latestLog->timestamp : null;
}
public function gatewayUser()
@@ -37,33 +76,14 @@ class ApiKey extends Model
return $this->belongsTo(GatewayUser::class, 'user_id', 'user_id');
}
// Alias for backwards compatibility
public function user()
{
return $this->gatewayUser();
}
public function usageLogs()
{
return $this->hasMany(UsageLog::class, 'api_key_id', 'id');
}
public function scopeActive($query)
{
return $query->where('is_active', true)
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
public function scopeExpired($query)
{
return $query->whereNotNull('expires_at')
->where('expires_at', '<=', now());
}
public function getMaskedKeyAttribute()
{
return 'gw-' . substr($this->id, 0, 8) . '...' . substr($this->id, -8);
}
public function getIsExpiredAttribute()
{
return $this->expires_at && $this->expires_at->isPast();
return $this->hasMany(UsageLog::class, 'api_key', 'token');
}
}

View File

@@ -2,47 +2,61 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Budget extends Model
{
use HasFactory;
protected $table = 'budgets';
protected $primaryKey = 'budget_id';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'budget_id',
'max_budget',
'budget_duration_sec',
'name',
'monthly_limit',
'daily_limit',
'created_by',
];
protected function casts(): array
protected $casts = [
'monthly_limit' => 'decimal:2',
'daily_limit' => 'decimal:2',
];
/**
* Get formatted max budget display
*/
public function getMaxBudgetFormattedAttribute(): string
{
return [
'max_budget' => 'double',
'budget_duration_sec' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
if ($this->monthly_limit) {
return '$' . number_format($this->monthly_limit, 2);
}
if ($this->daily_limit) {
return '$' . number_format($this->daily_limit, 2) . '/day';
}
return 'Unlimited';
}
/**
* Get human-readable duration
*/
public function getDurationHumanAttribute(): string
{
if ($this->monthly_limit && $this->daily_limit) {
return 'Monthly';
}
if ($this->daily_limit && !$this->monthly_limit) {
return 'Daily';
}
return 'Unlimited';
}
public function gatewayUsers()
{
return $this->hasMany(GatewayUser::class, 'budget_id', 'budget_id');
}
public function getMaxBudgetFormattedAttribute()
{
return '$' . number_format($this->max_budget, 2);
}
public function getDurationHumanAttribute()
{
if (!$this->budget_duration_sec) return 'No limit';
$days = floor($this->budget_duration_sec / 86400);
$hours = floor(($this->budget_duration_sec % 86400) / 3600);
return "{$days}d {$hours}h";
}
}

View File

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

View File

@@ -7,45 +7,53 @@ use Illuminate\Database\Eloquent\Model;
class ModelPricing extends Model
{
protected $table = 'model_pricing';
protected $primaryKey = 'model_key';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'model_key',
'provider',
'model',
'input_price_per_million',
'output_price_per_million',
'context_window',
'max_output_tokens',
'is_active',
'effective_from',
'effective_until',
'notes',
];
protected function casts(): array
{
return [
'input_price_per_million' => 'double',
'output_price_per_million' => 'double',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
protected $casts = [
'input_price_per_million' => 'decimal:4',
'output_price_per_million' => 'decimal:4',
'context_window' => 'integer',
'max_output_tokens' => 'integer',
'is_active' => 'boolean',
'effective_from' => 'date',
'effective_until' => 'date',
];
// Accessors
public function getInputPriceFormattedAttribute()
public function getInputPriceFormattedAttribute(): string
{
return '$' . number_format($this->input_price_per_million, 2) . '/M';
}
public function getOutputPriceFormattedAttribute()
public function getOutputPriceFormattedAttribute(): string
{
return '$' . number_format($this->output_price_per_million, 2) . '/M';
}
/**
* Calculate cost for given token counts
*/
public function calculateCost($inputTokens, $outputTokens)
public function calculateCost(int $inputTokens, int $outputTokens): float
{
$inputCost = ($inputTokens / 1000000) * $this->input_price_per_million;
$outputCost = ($outputTokens / 1000000) * $this->output_price_per_million;
$inputCost = ($inputTokens / 1_000_000) * $this->input_price_per_million;
$outputCost = ($outputTokens / 1_000_000) * $this->output_price_per_million;
return $inputCost + $outputCost;
return round($inputCost + $outputCost, 6);
}
public function isCurrentlyActive(): bool
{
$now = now()->toDateString();
return $this->is_active
&& $this->effective_from <= $now
&& ($this->effective_until === null || $this->effective_until >= $now);
}
}

View File

@@ -2,20 +2,23 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UsageLog extends Model
{
protected $primaryKey = 'id';
use HasFactory;
protected $table = 'usage_logs';
protected $primaryKey = 'request_id';
public $incrementing = false;
protected $keyType = 'string';
public $timestamps = false;
protected $fillable = [
'id',
'api_key_id',
'request_id',
'user_id',
'timestamp',
'api_key',
'model',
'provider',
'endpoint',
@@ -25,17 +28,22 @@ class UsageLog extends Model
'cost',
'status',
'error_message',
'timestamp',
'metadata',
];
protected function casts(): array
protected $casts = [
'prompt_tokens' => 'integer',
'completion_tokens' => 'integer',
'total_tokens' => 'integer',
'cost' => 'decimal:6',
'timestamp' => 'datetime',
'metadata' => 'array',
];
public function user()
{
return [
'timestamp' => 'datetime',
'prompt_tokens' => 'integer',
'completion_tokens' => 'integer',
'total_tokens' => 'integer',
'cost' => 'double',
];
return $this->belongsTo(GatewayUser::class, 'user_id', 'user_id');
}
public function gatewayUser()
@@ -45,9 +53,10 @@ class UsageLog extends Model
public function apiKey()
{
return $this->belongsTo(ApiKey::class, 'api_key_id', 'id');
return $this->belongsTo(ApiKey::class, 'api_key', 'token');
}
// Scopes
public function scopeSuccess($query)
{
return $query->where('status', 'success');
@@ -55,21 +64,6 @@ class UsageLog extends Model
public function scopeFailed($query)
{
return $query->where('status', '!=', 'success');
}
public function scopeToday($query)
{
return $query->whereDate('timestamp', today());
}
public function scopeDateRange($query, $start, $end)
{
return $query->whereBetween('timestamp', [$start, $end]);
}
public function getCostFormattedAttribute()
{
return $this->cost ? '$' . number_format($this->cost, 4) : 'N/A';
return $query->where('status', 'failed');
}
}

View File

@@ -45,4 +45,36 @@ class User extends Authenticatable
'password' => 'hashed',
];
}
/**
* Get the user's budget
*/
public function budget()
{
return $this->hasOne(UserBudget::class);
}
/**
* Get the user's rate limit
*/
public function rateLimit()
{
return $this->hasOne(RateLimit::class);
}
/**
* Get the user's provider credentials
*/
public function providerCredentials()
{
return $this->hasMany(UserProviderCredential::class);
}
/**
* Get the user's LLM requests
*/
public function llmRequests()
{
return $this->hasMany(LlmRequest::class);
}
}

View File

@@ -2,8 +2,9 @@
namespace App\Services;
use App\Models\UsageLog;
use App\Models\GatewayUser;
use App\Models\LlmRequest;
use App\Models\User;
use App\Models\UserProviderCredential;
use Illuminate\Support\Facades\DB;
class StatisticsService
@@ -11,27 +12,34 @@ class StatisticsService
/**
* Get dashboard overview statistics
*/
public function getDashboardStats()
public function getDashboardStats(): array
{
return [
'total_users' => GatewayUser::count(),
'active_users' => GatewayUser::active()->count(),
'blocked_users' => GatewayUser::blocked()->count(),
'total_requests_today' => UsageLog::today()->count(),
'total_spend_today' => UsageLog::today()->sum('cost') ?? 0,
'total_tokens_today' => UsageLog::today()->sum('total_tokens') ?? 0,
'total_spend_month' => UsageLog::whereMonth('timestamp', now()->month)->sum('cost') ?? 0,
'total_requests_month' => UsageLog::whereMonth('timestamp', now()->month)->count(),
'total_users' => User::count(),
'active_credentials' => UserProviderCredential::where('is_active', true)->count(),
'total_requests_today' => LlmRequest::whereDate('created_at', today())->count(),
'total_spend_today' => LlmRequest::whereDate('created_at', today())->sum('total_cost') ?? 0,
'total_tokens_today' => LlmRequest::whereDate('created_at', today())->sum('total_tokens') ?? 0,
'total_spend_month' => LlmRequest::whereMonth('created_at', now()->month)
->whereYear('created_at', now()->year)
->sum('total_cost') ?? 0,
'total_requests_month' => LlmRequest::whereMonth('created_at', now()->month)
->whereYear('created_at', now()->year)
->count(),
'avg_cost_per_request' => LlmRequest::whereMonth('created_at', now()->month)
->whereYear('created_at', now()->year)
->avg('total_cost') ?? 0,
];
}
/**
* Get usage breakdown by provider
*/
public function getUsageByProvider($days = 30)
public function getUsageByProvider(int $days = 30)
{
return UsageLog::selectRaw('provider, COUNT(*) as count, SUM(cost) as total_cost')
->where('timestamp', '>=', now()->subDays($days))
return LlmRequest::selectRaw('provider, COUNT(*) as count, SUM(total_cost) as total_cost, SUM(total_tokens) as total_tokens')
->where('created_at', '>=', now()->subDays($days))
->where('status', 'success')
->groupBy('provider')
->orderByDesc('count')
->get();
@@ -40,11 +48,12 @@ class StatisticsService
/**
* Get usage breakdown by model
*/
public function getUsageByModel($days = 30)
public function getUsageByModel(int $days = 30)
{
return UsageLog::selectRaw('model, COUNT(*) as count, SUM(total_tokens) as tokens, SUM(cost) as total_cost')
->where('timestamp', '>=', now()->subDays($days))
->groupBy('model')
return LlmRequest::selectRaw('model, provider, COUNT(*) as count, SUM(total_tokens) as tokens, SUM(total_cost) as total_cost')
->where('created_at', '>=', now()->subDays($days))
->where('status', 'success')
->groupBy('model', 'provider')
->orderByDesc('count')
->limit(10)
->get();
@@ -53,10 +62,11 @@ class StatisticsService
/**
* Get daily usage chart data
*/
public function getDailyUsageChart($days = 30)
public function getDailyUsageChart(int $days = 30)
{
return UsageLog::selectRaw('DATE(timestamp) as date, COUNT(*) as requests, SUM(cost) as cost, SUM(total_tokens) as tokens')
->where('timestamp', '>=', now()->subDays($days))
return LlmRequest::selectRaw('DATE(created_at) as date, COUNT(*) as requests, SUM(total_cost) as cost, SUM(total_tokens) as tokens')
->where('created_at', '>=', now()->subDays($days))
->where('status', 'success')
->groupBy('date')
->orderBy('date')
->get();
@@ -65,11 +75,13 @@ class StatisticsService
/**
* Get top users by spend
*/
public function getTopUsers($limit = 10)
public function getTopUsers(int $limit = 10)
{
return GatewayUser::withCount('usageLogs')
->withSum('usageLogs', 'cost')
->orderByDesc('usage_logs_sum_cost')
return User::select('users.*')
->withCount('llmRequests')
->withSum('llmRequests as total_cost', 'total_cost')
->withSum('llmRequests as total_tokens', 'total_tokens')
->orderByDesc('total_cost')
->limit($limit)
->get();
}
@@ -77,10 +89,10 @@ class StatisticsService
/**
* Get recent activity
*/
public function getRecentActivity($limit = 20)
public function getRecentActivity(int $limit = 20)
{
return UsageLog::with(['gatewayUser', 'apiKey'])
->orderByDesc('timestamp')
return LlmRequest::with('user')
->orderByDesc('created_at')
->limit($limit)
->get();
}
@@ -88,18 +100,82 @@ class StatisticsService
/**
* Get user statistics
*/
public function getUserStatistics($userId, $days = 30)
public function getUserStatistics(int $userId, int $days = 30)
{
return UsageLog::where('user_id', $userId)
->where('timestamp', '>=', now()->subDays($days))
return LlmRequest::where('user_id', $userId)
->where('created_at', '>=', now()->subDays($days))
->where('status', 'success')
->selectRaw('
COUNT(*) as total_requests,
SUM(prompt_tokens) as total_prompt_tokens,
SUM(completion_tokens) as total_completion_tokens,
SUM(total_tokens) as total_tokens,
SUM(cost) as total_cost,
AVG(total_tokens) as avg_tokens_per_request
SUM(total_cost) as total_cost,
AVG(total_tokens) as avg_tokens_per_request,
AVG(total_cost) as avg_cost_per_request,
AVG(response_time_ms) as avg_response_time_ms
')
->first();
}
/**
* Get provider usage over time
*/
public function getProviderUsageOverTime(int $days = 30)
{
return LlmRequest::selectRaw('DATE(created_at) as date, provider, COUNT(*) as count, SUM(total_cost) as cost')
->where('created_at', '>=', now()->subDays($days))
->where('status', 'success')
->groupBy('date', 'provider')
->orderBy('date')
->get()
->groupBy('provider');
}
/**
* Get cost trends
*/
public function getCostTrends(int $days = 30)
{
$data = LlmRequest::selectRaw('
DATE(created_at) as date,
SUM(total_cost) as daily_cost,
AVG(total_cost) as avg_request_cost,
COUNT(*) as request_count
')
->where('created_at', '>=', now()->subDays($days))
->where('status', 'success')
->groupBy('date')
->orderBy('date')
->get();
return [
'daily_data' => $data,
'total_cost' => $data->sum('daily_cost'),
'avg_daily_cost' => $data->avg('daily_cost'),
'total_requests' => $data->sum('request_count'),
];
}
/**
* Get error statistics
*/
public function getErrorStats(int $days = 30)
{
return [
'total_errors' => LlmRequest::where('created_at', '>=', now()->subDays($days))
->where('status', '!=', 'success')
->count(),
'errors_by_status' => LlmRequest::selectRaw('status, COUNT(*) as count')
->where('created_at', '>=', now()->subDays($days))
->where('status', '!=', 'success')
->groupBy('status')
->get(),
'errors_by_provider' => LlmRequest::selectRaw('provider, COUNT(*) as count')
->where('created_at', '>=', now()->subDays($days))
->where('status', '!=', 'success')
->groupBy('provider')
->get(),
];
}
}