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:
144
laravel-app/tests/Unit/Services/AnthropicProviderTest.php
Normal file
144
laravel-app/tests/Unit/Services/AnthropicProviderTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
105
laravel-app/tests/Unit/Services/CostCalculatorTest.php
Normal file
105
laravel-app/tests/Unit/Services/CostCalculatorTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
128
laravel-app/tests/Unit/Services/DeepSeekProviderTest.php
Normal file
128
laravel-app/tests/Unit/Services/DeepSeekProviderTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
147
laravel-app/tests/Unit/Services/GeminiProviderTest.php
Normal file
147
laravel-app/tests/Unit/Services/GeminiProviderTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
128
laravel-app/tests/Unit/Services/MistralProviderTest.php
Normal file
128
laravel-app/tests/Unit/Services/MistralProviderTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
150
laravel-app/tests/Unit/Services/OpenAIProviderTest.php
Normal file
150
laravel-app/tests/Unit/Services/OpenAIProviderTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
97
laravel-app/tests/Unit/Services/ProviderFactoryTest.php
Normal file
97
laravel-app/tests/Unit/Services/ProviderFactoryTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user