Aller au contenu principal
Retour au blog

Intégrer Claude 3.5 dans Symfony : service injectable, SSE et tokens

Flavien Métivier12 novembre 202410 min

Le 22 octobre 2024, Anthropic a mis à jour Claude 3.5 Sonnet — identifiant API : claude-3-5-sonnet-20241022 — en publiant des scores SWE-bench qui en font le modèle le plus performant sur les tâches de programmation à cette date. Si tu travailles sur une application Symfony et que tu veux intégrer cette puissance sans embarquer un SDK tiers fragile ni subir une breaking change un lundi matin, bonne nouvelle : Symfony HttpClient 7 gère nativement les appels REST et les flux Server-Sent Events. Ce tutoriel construit de A à Z un service injectable, une route de streaming et un suivi de tokens réaliste — avec une section honnête sur ce qui coince encore en production.

Pourquoi HttpClient natif plutôt qu'un SDK ?

Il n'existe pas de SDK PHP officiel signé Anthropic au moment de cet article. Des wrappers communautaires existent sur Packagist, mais ils ajoutent une dépendance superflue : l'API Anthropic Messages est une API REST JSON avec un seul endpoint, des headers simples et une réponse prévisible. Symfony HttpClient (symfony/http-client) sait faire du POST JSON, lire un flux SSE chunké et gérer les timeouts et les retries — sans rien d'autre. On garde le contrôle total, on audite chaque ligne en code review, et on évite d'embarquer un client HTTP concurrent parfois en conflit avec la configuration Symfony. Le bilan : moins de dépendances, plus de lisibilité, zéro magie cachée.

Prérequis et configuration

  • PHP 8.3 (stable LTS à cette date — PHP 8.4 est encore en Release Candidate)
  • Symfony 7.1 avec autowiring activé (configuration par défaut)
  • Le composant symfony/http-client installé via Composer
  • Une clé API Anthropic créée sur console.anthropic.com
composer require symfony/http-client
# .env — commité, valeur vide pour documenter la variable
ANTHROPIC_API_KEY=

# .env.local — NON commité, valeur réelle
ANTHROPIC_API_KEY=sk-ant-api03-...

En production, préfère les secrets Symfony (bin/console secrets:set ANTHROPIC_API_KEY) ou les variables d'environnement injectées par ton orchestrateur (Docker, Kubernetes). L'approche .env.local reste réservée au développement local uniquement.

Le service AnthropicClient : injection et structure

Le service porte deux responsabilités : un appel synchrone qui attend la réponse complète (utile pour des traitements batch ou des résumés non interactifs), et un appel en streaming qui pousse chaque fragment dès réception. L'attribut #[Autowire(env: 'ANTHROPIC_API_KEY')], disponible depuis Symfony 6.3, injecte la variable d'environnement directement sans toucher à services.yaml. La constante typée const string — nouveauté PHP 8.3 — s'utilise ici sans hésiter.

<?php

declare(strict_types=1);

namespace App\AI;

use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class AnthropicClient
{
    private const string API_URL     = 'https://api.anthropic.com/v1/messages';
    private const string API_VERSION = '2023-06-01';
    private const string MODEL       = 'claude-3-5-sonnet-20241022';

    public function __construct(
        private readonly HttpClientInterface $httpClient,
        #[Autowire(env: 'ANTHROPIC_API_KEY')]
        private readonly string $apiKey,
    ) {}

    /**
     * Appel synchrone — retourne la réponse complète d'un coup.
     *
     * @param list<array{role: 'user'|'assistant', content: string}> $messages
     * @return array{content: list<array{text: string}>, usage: array{input_tokens: int, output_tokens: int}}
     */
    public function complete(array $messages, int $maxTokens = 1024): array
    {
        $response = $this->httpClient->request('POST', self::API_URL, [
            'headers' => $this->headers(),
            'json'    => [
                'model'      => self::MODEL,
                'max_tokens' => $maxTokens,
                'messages'   => $messages,
            ],
        ]);

        return $response->toArray();
    }

    /**
     * Streaming SSE — $onChunk est appelé pour chaque fragment de texte reçu.
     *
     * @param list<array{role: 'user'|'assistant', content: string}> $messages
     * @param callable(string): void                                  $onChunk
     * @return array{input_tokens: int, output_tokens: int}
     */
    public function stream(array $messages, callable $onChunk, int $maxTokens = 1024): array
    {
        $response = $this->httpClient->request('POST', self::API_URL, [
            'headers' => $this->headers(),
            'json'    => [
                'model'      => self::MODEL,
                'max_tokens' => $maxTokens,
                'messages'   => $messages,
                'stream'     => true,
            ],
        ]);

        $buffer       = '';
        $inputTokens  = 0;
        $outputTokens = 0;

        foreach ($this->httpClient->stream($response) as $chunk) {
            if ($chunk->isLast()) {
                break;
            }

            $buffer .= $chunk->getContent();

            // Traitement ligne à ligne pour absorber les coupures de chunk
            while (false !== ($pos = strpos($buffer, "\n"))) {
                $line   = rtrim(substr($buffer, 0, $pos));
                $buffer = substr($buffer, $pos + 1);

                if (!str_starts_with($line, 'data: ')) {
                    continue;
                }

                $data = json_decode(substr($line, 6), true);
                if (!is_array($data)) {
                    continue;
                }

                if ('message_start' === $data['type']) {
                    $inputTokens = $data['message']['usage']['input_tokens'] ?? 0;
                } elseif ('content_block_delta' === $data['type'] && isset($data['delta']['text'])) {
                    $onChunk($data['delta']['text']);
                } elseif ('message_delta' === $data['type']) {
                    $outputTokens = $data['usage']['output_tokens'] ?? 0;
                }
            }
        }

        return ['input_tokens' => $inputTokens, 'output_tokens' => $outputTokens];
    }

    /** @return array<string, string> */
    private function headers(): array
    {
        return [
            'x-api-key'         => $this->apiKey,
            'anthropic-version' => self::API_VERSION,
            'content-type'      => 'application/json',
        ];
    }
}

Streaming SSE : StreamedResponse côté serveur

Pour le rendu progressif, on combine AnthropicClient::stream() et StreamedResponse de Symfony. Chaque fragment de texte est émis dès qu'il arrive depuis Anthropic, et l'utilisateur voit la réponse s'écrire mot par mot. Trois headers sont obligatoires : Content-Type: text/event-stream pour que le navigateur reconnaisse le protocole SSE, Cache-Control: no-cache pour éviter la mise en tampon des événements, et surtout X-Accel-Buffering: no pour désactiver le buffering Nginx. Sans ce dernier, les tokens arrivent en bloc à la fin — exactement l'effet inverse du streaming. Le connection_aborted() dans la closure évite de continuer à consommer des tokens facturés si le navigateur a fermé la connexion en cours de route.

Besoin d'un expert Symfony ?

Réserver un appel
<?php

declare(strict_types=1);

namespace App\Controller;

use App\AI\AnthropicClient;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;

final class ChatController extends AbstractController
{
    #[Route('/api/chat/stream', name: 'api_chat_stream', methods: ['GET'])]
    public function stream(Request $request, AnthropicClient $claude): StreamedResponse
    {
        $prompt = $request->query->getString('q');

        if ('' === $prompt) {
            throw $this->createNotFoundException('Paramètre q manquant.');
        }

        $messages = [['role' => 'user', 'content' => $prompt]];

        return new StreamedResponse(
            function () use ($claude, $messages): void {
                $usage = $claude->stream(
                    messages: $messages,
                    onChunk: static function (string $text): void {
                        if (connection_aborted()) {
                            exit;
                        }
                        echo 'data: ' . json_encode(['text' => $text], \JSON_UNESCAPED_UNICODE) . "\n\n";
                        ob_flush();
                        flush();
                    },
                );

                // Événement final : on pousse l'usage pour le suivi client
                echo 'data: ' . json_encode(['done' => true, 'usage' => $usage], \JSON_UNESCAPED_UNICODE) . "\n\n";
                ob_flush();
                flush();
            },
            200,
            [
                'Content-Type'      => 'text/event-stream',
                'Cache-Control'     => 'no-cache, no-store',
                'X-Accel-Buffering' => 'no',
            ]
        );
    }
}

Consommer le flux SSE en JavaScript

L'API EventSource du navigateur est taillée pour ce cas : elle gère automatiquement les reconnexions et maintient une connexion HTTP persistante. On parse chaque événement data: en JSON, en distinguant les fragments de texte de l'événement de fin qui transporte l'usage de tokens. À noter : EventSource ne supporte que GET nativement. Pour des prompts longs ou des données sensibles, préfère une étape préalable côté serveur — génération d'un token de session, puis SSE en GET portant uniquement cet identifiant.

function askClaude(question) {
    const output = document.getElementById('output');
    const stats  = document.getElementById('stats');

    output.textContent = '';
    stats.textContent  = '';

    const src = new EventSource('/api/chat/stream?q=' + encodeURIComponent(question));

    src.onmessage = (event) => {
        const data = JSON.parse(event.data);

        if (data.done) {
            src.close();
            const { input_tokens, output_tokens } = data.usage;
            // Tarif Claude 3.5 Sonnet nov. 2024 : $3/M input · $15/M output
            const costUsd = ((input_tokens * 3 + output_tokens * 15) / 1_000_000).toFixed(6);
            stats.textContent = `${input_tokens} tokens entrants · ${output_tokens} tokens sortants · ~${costUsd}`;
            return;
        }

        output.textContent += data.text;
    };

    src.onerror = () => {
        src.close();
        output.textContent += '\n[Flux interrompu — recharge la page pour réessayer]';
    };
}

Comptage de tokens : surveiller sa facture

Anthropic facture à la consommation : en novembre 2024, Claude 3.5 Sonnet coûte 3 $ pour 1 million de tokens en entrée et 15 $ pour 1 million en sortie. Des chiffres faibles au token individuel, mais qui s'accumulent vite sur des conversations longues — parce que tu renvoies tout l'historique à chaque tour. L'événement SSE message_start expose les input_tokens dès l'ouverture du flux ; message_delta expose les output_tokens finaux une fois la génération terminée. En mode synchrone, l'objet usage est directement dans la réponse JSON. Voici un exemple minimal de journalisation à brancher dans un Listener ou directement dans le service appelant :

// À injecter dans le service ou un event listener dédié
private function logUsage(string $sessionId, array $usage): void
{
    // $usage = ['input_tokens' => 234, 'output_tokens' => 891]
    $costUsd = ($usage['input_tokens'] * 3 + $usage['output_tokens'] * 15) / 1_000_000;

    $this->logger->info('anthropic.usage', [
        'session'       => $sessionId,
        'input_tokens'  => $usage['input_tokens'],
        'output_tokens' => $usage['output_tokens'],
        'cost_usd'      => round($costUsd, 6),
        'model'         => 'claude-3-5-sonnet-20241022',
    ]);

    // En production : persister en base pour budgéter par tenant/utilisateur
    // $this->usageRepository->save(new TokenUsage($sessionId, $usage, $costUsd));
}

Limites honnêtes à connaître avant de mettre en prod

Ce service minimal fonctionne, mais il ne couvre pas tout le périmètre d'une intégration robuste. Voici les points à adresser avant un déploiement sous charge réelle :

  • Rate limits Anthropic (Tier 1 par défaut : ~50 requêtes/min, ~40 000 tokens/min) — prévoir une queue (Symfony Messenger) ou un backoff exponentiel sur les 429
  • Erreur 529 (serveur Anthropic surchargé) : non gérée ici, à capturer avec un try/catch sur TransportExceptionInterface et un retry différé
  • Streaming non annulable côté Anthropic : connection_aborted() stoppe la sortie HTTP, mais la requête vers l'API continue jusqu'à épuisement des tokens générés — une annulation propre nécessite une couche de coordination externe (Redis, Symfony Messenger)
  • Gestion du contexte conversation absente : ce service est sans état — la mémoire multi-tours doit être construite côté applicatif en sérialisant l'historique messages[] entre chaque appel

Ce tutoriel te donne la base fonctionnelle : un service qui tient en moins de cent lignes, qui s'injecte proprement dans le conteneur Symfony, et qui suffit pour valider ton cas d'usage en quelques heures. L'étape suivante dépend de tes contraintes — quota Anthropic, persistance de l'historique, architecture multi-tenant — mais chaque brique s'ajoute sans réécrire ce socle.

Cet article vous a plu ? Partagez-le !

Besoin d'un expert Symfony ?

20 ans d'expérience sur l'écosystème PHP/Symfony.