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