Factory des clients IA Anthropic et Gemini en PHP avec Symfony

L'intégration de multiples fournisseurs d'IA dans une application moderne nécessite une architecture flexible et extensible. Cet article présente une solution complète utilisant le pattern Factory pour gérer les clients IA Anthropic Claude et Google Gemini dans une application Symfony.

Architecture de la solution

Composants principaux

  1. Factory Pattern : AIClientFactory pour la création des clients
  2. Interface commune : AIClientInterface pour l'uniformisation
  3. Clients spécialisés : AnthropicClient et GeminiClient
  4. Modèles d'énumération : AIModel pour la gestion des modèles
  5. Commande de test : QuickAITestCommand pour la validation

Flux de fonctionnement

AIModel → AIClientFactory → Client Spécifique → API Provider

Implémentation de la Factory

1. Interface commune

<?php

namespace App\AI\Interface;

interface AIClientInterface
{
    /**
     * Envoie un message au modèle IA et retourne la réponse
     */
    public function sendMessage(array $params): array;

    /**
     * Génère du contenu basé sur les paramètres fournis
     */
    public function generateContent(array $params): array;

    /**
     * Effectue une analyse de sentiment sur le texte fourni
     */
    public function analyzeSentiment(string $text): array;

    /**
     * Traduit le texte vers la langue cible
     */
    public function translateText(string $text, string $targetLanguage, ?string $sourceLanguage = null): array;

    /**
     * Résume le texte fourni
     */
    public function summarizeText(string $text, int $maxLength = 150): array;

    /**
     * Classifie le texte selon les catégories fournies
     */
    public function classifyText(string $text, array $categories): array;
}

2. Énumération des modèles IA

<?php

namespace App\AI\Model;

enum AIModel: string
{
    case GEMINI_2_5_FLASH = 'gemini-2.5-flash';
    case CLAUDE_3_5_SONNET = 'claude-3-5-sonnet-20241022';

    public function getProvider(): string
    {
        return match ($this) {
            self::GEMINI_2_5_FLASH => 'google',
            self::CLAUDE_3_5_SONNET => 'anthropic',
        };
    }

    public function getDisplayName(): string
    {
        return match ($this) {
            self::GEMINI_2_5_FLASH => 'Gemini 2.5 Flash',
            self::CLAUDE_3_5_SONNET => 'Claude 3.5 Sonnet',
        };
    }
}

3. Factory principale

<?php

namespace App\AI\Factory;

use App\AI\Client\AnthropicAIClient;
use App\AI\Client\GeminiClient;
use App\AI\Interface\AIClientInterface;
use App\AI\Model\AIModel;
use InvalidArgumentException;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;

class AIClientFactory
{
    private string $geminiApiKey;

    public function __construct(
        ParameterBagInterface $parameterBag,
    )
    {
        $this->geminiApiKey = $parameterBag->get('app.ia.gemini.api_key');
    }

    /**
     * Crée un client IA basé sur le modèle fourni
     */
    public function create(AIModel $model, array $config = []): AIClientInterface
    {
        return match ($model->getProvider()) {
            'google' => new GeminiClient($model, array_merge($config, ['api_key' => $this->geminiApiKey])),
            'anthropic' => new AnthropicAIClient($model, $config),
            default => throw new InvalidArgumentException(sprintf('Fournisseur IA non supporté : %s', $model->getProvider())),
        };
    }

    public function createFromModelName(string $modelName, array $config = []): ?AIClientInterface
    {
        $model = AIModel::tryFrom($modelName);

        if (!$model) {
            return null;
        }

        return $this->create($model, $config);
    }
}

Implémentation des clients IA

1. Classe abstraite de base

<?php

namespace App\AI\Client;

use App\AI\Exception\NotImplementedException;
use App\AI\Interface\AIClientInterface;
use App\AI\Model\AIModel;

abstract class AbstractAIClient implements AIClientInterface
{
    protected AIModel $model;
    protected array $config;

    public function __construct(AIModel $model, array $config = [])
    {
        $this->model = $model;
        $this->config = $config;
    }

    public function generateContent(array $params): array
    {
        throw new NotImplementedException('generateContent', $this->getClientName());
    }

    public function analyzeSentiment(string $text): array
    {
        throw new NotImplementedException('analyzeSentiment', $this->getClientName());
    }

    public function translateText(string $text, string $targetLanguage, ?string $sourceLanguage = null): array
    {
        throw new NotImplementedException('translateText', $this->getClientName());
    }

    public function summarizeText(string $text, int $maxLength = 150): array
    {
        throw new NotImplementedException('summarizeText', $this->getClientName());
    }

    public function classifyText(string $text, array $categories): array
    {
        throw new NotImplementedException('classifyText', $this->getClientName());
    }

    abstract protected function getClientName(): string;
}

2. Client Gemini

<?php

namespace App\AI\Client;

use Gemini\Data\GoogleSearch;
use Gemini\Data\Tool;
use Gemini\Data\UrlContext;
use Gemini\Resources\GenerativeModel;
use Gemini\Data\GenerationConfig;

class GeminiClient extends AbstractAIClient
{
    private ?GenerativeModel $client = null;

    public function sendMessage(array $params): array
    {
        $client = $this->getClient();
        
        $prompt = $params['prompt'] ?? '';
        $temperature = $params['temperature'] ?? 0.7;
        $maxTokens = $params['maxTokens'] ?? 1000;

        // Configurer le client avec les paramètres de génération
        $client = $client->withGenerationConfig(
            new GenerationConfig(
                maxOutputTokens: $maxTokens,
                temperature: $temperature
            )
        );

        $result = $client->generateContent($prompt);
        return [
            'content' => $result->text(),
            'model' => $this->model->value,
            'usage' => [
                'promptTokens' => $result->usageMetadata?->promptTokenCount ?? 0,
                'responseTokens' => $result->usageMetadata?->candidatesTokenCount ?? 0,
                'totalTokens' => $result->usageMetadata?->totalTokenCount ?? 0,
            ],
        ];
    }

    public function generateContent(array $params): array
    {
        return $this->sendMessage($params);
    }

    public function summarizeText(string $text, int $maxLength = 150): array
    {
        $prompt = sprintf(
            'Résume le texte suivant en maximum %d caractères : %s',
            $maxLength,
            $text
        );

        return $this->sendMessage(['prompt' => $prompt]);
    }

    public function translateText(string $text, string $targetLanguage, ?string $sourceLanguage = null): array
    {
        $prompt = sprintf(
            'Traduis le texte suivant en %s%s : %s',
            $targetLanguage,
            $sourceLanguage ? " depuis le $sourceLanguage" : '',
            $text
        );

        return $this->sendMessage(['prompt' => $prompt]);
    }

    public function analyzeSentiment(string $text): array
    {
        $prompt = sprintf(
            'Analyse le sentiment du texte suivant et retourne une réponse au format JSON avec les champs "sentiment" (positif, négatif, neutre) et "score" (entre 0 et 1) : %s',
            $text
        );

        $result = $this->sendMessage(['prompt' => $prompt]);
        
        // Tentative de parsing JSON de la réponse
        $content = $result['content'];
        if (preg_match('/\{.*\}/s', $content, $matches)) {
            $json = json_decode($matches[0], true);
            if ($json) {
                return array_merge($result, ['analysis' => $json]);
            }
        }

        return $result;
    }

    public function classifyText(string $text, array $categories): array
    {
        $categoriesList = implode(', ', $categories);
        $prompt = sprintf(
            'Classifie le texte suivant dans une des catégories suivantes : %s. Retourne une réponse au format JSON avec les champs "category" et "confidence" (entre 0 et 1) : %s',
            $categoriesList,
            $text
        );

        $result = $this->sendMessage(['prompt' => $prompt]);
        
        // Tentative de parsing JSON de la réponse
        $content = $result['content'];
        if (preg_match('/\{.*\}/s', $content, $matches)) {
            $json = json_decode($matches[0], true);
            if ($json) {
                return array_merge($result, ['classification' => $json]);
            }
        }

        return $result;
    }

    private function getClient(): GenerativeModel
    {
        if ($this->client === null) {
            $apiKey = $this->config['api_key'] ?? null;
            
            if (!$apiKey) {
                throw new \InvalidArgumentException('Clé API Gemini manquante');
            }

            $client = \Gemini::client($apiKey);
            
            $this->client = $client
                ->generativeModel($this->model->value)
                ->withTool(new Tool(
                    googleSearch: new GoogleSearch(),
                    urlContext: new UrlContext(),
                ))
            ;
        }

        return $this->client;
    }

    protected function getClientName(): string
    {
        return 'Gemini';
    }
}

3. Client Anthropic

<?php

namespace App\AI\Client;

use App\AI\Model\AIModel;
use Anthropic\Client as AnthropicSDKClient;
use Anthropic\Resources\Messages;
use Anthropic\Anthropic;

class AnthropicAIClient extends AbstractAIClient
{
    private ?AnthropicSDKClient $client = null;

    public function sendMessage(array $params): array
    {
        $client = $this->getClient();
        
        $prompt = $params['prompt'] ?? '';
        $maxTokens = $params['maxTokens'] ?? 1000;
        $temperature = $params['temperature'] ?? 0.7;
        
        $response = $client->messages()->create([
            'model' => $this->model->value,
            'max_tokens' => $maxTokens,
            'temperature' => $temperature,
            'messages' => [
                [
                    'role' => 'user',
                    'content' => $prompt,
                ],
            ],
        ]);
        
        return [
            'content' => $response->content[0]->text,
            'model' => $this->model->value,
            'usage' => [
                'promptTokens' => $response->usage->inputTokens,
                'responseTokens' => $response->usage->outputTokens,
                'totalTokens' => $response->usage->inputTokens + $response->usage->outputTokens,
            ],
        ];
    }

    public function generateContent(array $params): array
    {
        return $this->sendMessage($params);
    }

    public function summarizeText(string $text, int $maxLength = 150): array
    {
        $prompt = sprintf(
            'Résume le texte suivant en maximum %d caractères : %s',
            $maxLength,
            $text
        );

        return $this->sendMessage(['prompt' => $prompt]);
    }

    public function translateText(string $text, string $targetLanguage, ?string $sourceLanguage = null): array
    {
        $prompt = sprintf(
            'Traduis le texte suivant en %s%s : %s',
            $targetLanguage,
            $sourceLanguage ? " depuis le $sourceLanguage" : '',
            $text
        );

        return $this->sendMessage(['prompt' => $prompt]);
    }

    public function analyzeSentiment(string $text): array
    {
        $prompt = sprintf(
            'Analyse le sentiment du texte suivant et retourne une réponse au format JSON avec les champs "sentiment" (positif, négatif, neutre) et "score" (entre 0 et 1) : %s',
            $text
        );

        $result = $this->sendMessage(['prompt' => $prompt]);
        
        // Tentative de parsing JSON de la réponse
        $content = $result['content'];
        if (preg_match('/\{.*\}/s', $content, $matches)) {
            $json = json_decode($matches[0], true);
            if ($json) {
                return array_merge($result, ['analysis' => $json]);
            }
        }

        return $result;
    }

    public function classifyText(string $text, array $categories): array
    {
        $categoriesList = implode(', ', $categories);
        $prompt = sprintf(
            'Classifie le texte suivant dans une des catégories suivantes : %s. Retourne une réponse au format JSON avec les champs "category" et "confidence" (entre 0 et 1) : %s',
            $categoriesList,
            $text
        );

        $result = $this->sendMessage(['prompt' => $prompt]);
        
        // Tentative de parsing JSON de la réponse
        $content = $result['content'];
        if (preg_match('/\{.*\}/s', $content, $matches)) {
            $json = json_decode($matches[0], true);
            if ($json) {
                return array_merge($result, ['classification' => $json]);
            }
        }

        return $result;
    }

    private function getClient(): AnthropicSDKClient
    {
        if ($this->client === null) {
            $apiKey = $this->config['api_key'] ?? $_ENV['ANTHROPIC_API_KEY'] ?? null;
            
            if (!$apiKey) {
                throw new \InvalidArgumentException('Clé API Anthropic manquante');
            }

            $this->client = Anthropic::client($apiKey);
        }

        return $this->client;
    }

    protected function getClientName(): string
    {
        return 'Anthropic';
    }
}

Commande de test rapide

1. Implémentation de la commande

<?php

namespace App\Command;

use App\AI\Factory\AIClientFactory;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'app:quick-ai-test',
    description: 'Test rapide d\'un client IA spécifique',
)]
class QuickAITestCommand extends Command
{
    public function __construct(private AIClientFactory $aiClientFactory)
    {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addArgument('model', InputArgument::REQUIRED, 'Modèle à tester (gemini-2.5-flash, claude-3-5-sonnet, etc.)')
            ->addOption('prompt', 'p', InputOption::VALUE_REQUIRED, 'Prompt à envoyer', 'Dis-moi bonjour en français')
            ->addOption('max-tokens', null, InputOption::VALUE_REQUIRED, 'Nombre maximum de tokens', 100)
            ->addOption('temperature', null, InputOption::VALUE_REQUIRED, 'Température (0.0 à 1.0)', 0.7)
            ->addOption('show-usage', null, InputOption::VALUE_NONE, 'Affiche les statistiques d\'utilisation')
            ->setHelp('Teste rapidement un client IA avec un prompt simple.');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $modelName = $input->getArgument('model');
        $prompt = $input->getOption('prompt');
        $maxTokens = (int) $input->getOption('max-tokens');
        $temperature = (float) $input->getOption('temperature');
        $showUsage = $input->getOption('show-usage');

        $io->title("🚀 Test Rapide - {$modelName}");

        try {
            // Créer le client
            $io->text("Création du client...");
            $client = $this->aiClientFactory->createFromModelName($modelName);
            $io->text("✓ Client créé avec succès");

            // Envoyer le message
            $io->text("Envoi du message...");
            $startTime = microtime(true);
            
            $response = $client->sendMessage([
                'prompt' => $prompt,
                'maxTokens' => $maxTokens,
                'temperature' => $temperature,
            ]);
            
            $endTime = microtime(true);
            $executionTime = $endTime - $startTime;

            $io->text("✓ Message envoyé avec succès (" . number_format($executionTime, 3) . "s)");

            // Afficher la réponse
            $io->section("Réponse");
            $io->text($response['content'] ?? 'Aucune réponse reçue');

            if ($showUsage && isset($response['usage'])) {
                $io->section("📊 Statistiques d'utilisation");
                $usage = $response['usage'];
                $io->table(
                    ['Métrique', 'Valeur'],
                    [
                        ['Tokens d\'entrée', $usage['promptTokens'] ?? 'N/A'],
                        ['Tokens de sortie', $usage['responseTokens'] ?? 'N/A'],
                        ['Total tokens', $usage['totalTokens'] ?? 'N/A'],
                        ['Temps d\'exécution', number_format($executionTime, 3) . 's'],
                    ]
                );
            }

            $io->section("🧪 Tests supplémentaires");
            
            $additionalTests = [
                'Analyse de sentiment' => fn() => $client->analyzeSentiment('Ce test est excellent !'),
                'Traduction' => fn() => $client->translateText('Hello world', 'français', 'anglais'),
                'Résumé' => fn() => $client->summarizeText('L\'intelligence artificielle est un domaine fascinant.', 50),
                'Classification' => fn() => $client->classifyText('Super produit !', ['positif', 'négatif', 'neutre']),
            ];

            foreach ($additionalTests as $testName => $testFunction) {
                try {
                    $testStartTime = microtime(true);
                    $testResult = $testFunction();
                    $testEndTime = microtime(true);
                    
                    $io->text("✓ {$testName} (" . number_format($testEndTime - $testStartTime, 3) . "s)");
                    
                    if (isset($testResult['content'])) {
                        $content = $testResult['content'];
                        $io->text("  " . substr($content, 0, 80) . (strlen($content) > 80 ? '...' : ''));
                    }
                    
                } catch (\Exception $e) {
                    $io->text("✗ {$testName}: " . $e->getMessage());
                }
            }

            $io->success("Test terminé avec succès !");

        } catch (\Exception $e) {
            $io->error("Erreur lors du test : " . $e->getMessage());
            
            return Command::FAILURE;
        }

        return Command::SUCCESS;
    }
}

2. Utilisation de la commande

# Test basique
php bin/console app:quick-ai-test gemini-2.5-flash

# Test avec prompt personnalisé
php bin/console app:quick-ai-test claude-3-5-sonnet --prompt="Explique-moi l'intelligence artificielle"

# Test avec paramètres avancés
php bin/console app:quick-ai-test gemini-2.5-flash \
    --prompt="Génère un poème sur la technologie" \
    --max-tokens=200 \
    --temperature=0.9 \
    --show-usage

Configuration Symfony

1. Configuration des services

# config/services.yaml
parameters:
    app.ia.gemini.api_key: '%env(GEMINI_API_KEY)%'

services:
    App\AI\Factory\AIClientFactory:
        arguments:
            $parameterBag: '@parameter_bag'
    
    App\Command\QuickAITestCommand:
        arguments:
            $aiClientFactory: '@App\AI\Factory\AIClientFactory'

2. Variables d'environnement

# .env.local
GEMINI_API_KEY=your_gemini_api_key_here
ANTHROPIC_API_KEY=your_anthropic_api_key_here

Fonctionnalités avancées

1. Gestion des erreurs

<?php

namespace App\AI\Exception;

class NotImplementedException extends \Exception
{
    public function __construct(string $method, string $clientName)
    {
        parent::__construct("La méthode {$method} n'est pas implémentée pour le client {$clientName}");
    }
}

2. Validation des paramètres

public function validateParameters(array $params): void
{
    $required = ['prompt'];
    foreach ($required as $field) {
        if (!isset($params[$field]) || empty($params[$field])) {
            throw new \InvalidArgumentException("Le paramètre '{$field}' est requis");
        }
    }

    if (isset($params['temperature']) && ($params['temperature'] < 0 || $params['temperature'] > 1)) {
        throw new \InvalidArgumentException("La température doit être entre 0 et 1");
    }
}

3. Cache des clients

private array $clientCache = [];

private function getCachedClient(string $key): ?AIClientInterface
{
    return $this->clientCache[$key] ?? null;
}

private function cacheClient(string $key, AIClientInterface $client): void
{
    $this->clientCache[$key] = $client;
}

Tests automatisés

1. Test unitaire de la factory

<?php

namespace App\Tests\AI\Factory;

use App\AI\Factory\AIClientFactory;
use App\AI\Model\AIModel;
use PHPUnit\Framework\TestCase;

class AIClientFactoryTest extends TestCase
{
    public function testCreateGeminiClient(): void
    {
        $factory = new AIClientFactory(['app.ia.gemini.api_key' => 'test_key']);
        
        $client = $factory->create(AIModel::GEMINI_2_5_FLASH);
        
        $this->assertInstanceOf(\App\AI\Client\GeminiClient::class, $client);
    }

    public function testCreateAnthropicClient(): void
    {
        $factory = new AIClientFactory(['app.ia.gemini.api_key' => 'test_key']);
        
        $client = $factory->create(AIModel::CLAUDE_3_5_SONNET);
        
        $this->assertInstanceOf(\App\AI\Client\AnthropicAIClient::class, $client);
    }
}

2. Test d'intégration

<?php

namespace App\Tests\Integration;

use App\AI\Factory\AIClientFactory;
use App\AI\Model\AIModel;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class AIClientIntegrationTest extends KernelTestCase
{
    public function testEndToEndWorkflow(): void
    {
        $factory = static::getContainer()->get(AIClientFactory::class);
        
        $client = $factory->create(AIModel::GEMINI_2_5_FLASH);
        
        $response = $client->sendMessage([
            'prompt' => 'Dis bonjour',
            'maxTokens' => 50,
            'temperature' => 0.7,
        ]);
        
        $this->assertArrayHasKey('content', $response);
        $this->assertNotEmpty($response['content']);
    }
}

Optimisations et bonnes pratiques

1. Gestion des timeouts

private function configureTimeout(int $timeout = 30): void
{
    $this->config['timeout'] = $timeout;
}

private function getTimeout(): int
{
    return $this->config['timeout'] ?? 30;
}

2. Retry automatique

public function sendMessageWithRetry(array $params, int $maxRetries = 3): array
{
    $attempts = 0;
    
    while ($attempts < $maxRetries) {
        try {
            return $this->sendMessage($params);
        } catch (\Exception $e) {
            $attempts++;
            
            if ($attempts >= $maxRetries) {
                throw $e;
            }
            
            sleep(2 ** $attempts); // Backoff exponentiel
        }
    }
}

3. Logging des requêtes

private function logRequest(array $params, array $response, float $executionTime): void
{
    $this->logger->info('AI Request', [
        'model' => $this->model->value,
        'provider' => $this->model->getProvider(),
        'execution_time' => $executionTime,
        'tokens_used' => $response['usage']['totalTokens'] ?? 0,
        'timestamp' => new \DateTime(),
    ]);
}

Déploiement et maintenance

1. Configuration de production

# config/packages/prod/ai.yaml
services:
    App\AI\Factory\AIClientFactory:
        arguments:
            $parameterBag: '@parameter_bag'
        tags:
            - { name: 'monolog.logger', channel: 'ai' }

2. Monitoring des performances

// Métriques de performance
private function trackPerformance(string $operation, float $startTime): void
{
    $executionTime = microtime(true) - $startTime;
    
    $this->metrics->increment('ai.requests.total');
    $this->metrics->histogram('ai.requests.duration', $executionTime);
    $this->metrics->increment("ai.requests.{$operation}");
}

3. Gestion des quotas

public function checkQuota(string $provider): bool
{
    $quotaKey = "ai.quota.{$provider}";
    $currentUsage = $this->redis->get($quotaKey) ?? 0;
    
    if ($currentUsage >= $this->getQuotaLimit($provider)) {
        throw new QuotaExceededException("Quota dépassé pour {$provider}");
    }
    
    $this->redis->incr($quotaKey);
    return true;
}

Conclusion

L'implémentation d'une factory pattern pour les clients IA offre de nombreux avantages :

Extensibilité : Ajout facile de nouveaux fournisseurs
Maintenabilité : Code modulaire et bien structuré
Testabilité : Interface commune facilitant les tests
Flexibilité : Configuration dynamique des clients
Performance : Cache et optimisations intégrés

Cette architecture permet une gestion centralisée des clients IA tout en conservant la flexibilité nécessaire pour s'adapter aux différents fournisseurs et modèles.

Ressources utiles :