Salesforce · IA · Intégration API

Appeler l’API Claude depuis Salesforce Apex

Résumer automatiquement une demande de support avec Claude, depuis Apex, sans clé dans le code : Named Credential, appel HTTP, traitement asynchrone, gestion des erreurs et tests.

Le besoin

Dans une organisation de démonstration, chaque demande de support (objet Case) reçoit une description souvent longue. On veut un résumé en trois phrases, stocké dans un champ AI_Summary__c, calculé sans bloquer l’utilisateur.

Il n’existe pas de SDK Anthropic pour Apex : on appelle directement l’API HTTP. Le même schéma s’applique à toute autre tâche de texte (classification, extraction, reformulation).

Prérequis : une clé API Anthropic (console Anthropic), une sandbox ou une org Developer, les droits d’administration pour créer les identifiants nommés.

L’API Messages en bref

Tout passe par POST https://api.anthropic.com/v1/messages avec trois en-têtes : x-api-key (la clé), anthropic-version: 2023-06-01 et content-type: application/json. Le corps indique le modèle, la longueur maximale de la réponse et les messages.

curl https://api.anthropic.com/v1/messages \
  -H "content-type: application/json" \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -d '{
    "model": "claude-opus-5-5",
    "max_tokens": 1024,
    "messages": [{"role": "user", "content": "Bonjour"}]
  }'

La réponse contient une liste de blocs. Avec Claude Opus 5.5, le modèle réfléchit avant de répondre : un bloc thinking (vide par défaut) peut précéder le bloc text. Il faut donc filtrer par type plutôt que lire le premier bloc.

{
  "id": "msg_...",
  "type": "message",
  "role": "assistant",
  "model": "claude-opus-5-5",
  "content": [
    { "type": "thinking", "thinking": "", "signature": "..." },
    { "type": "text", "text": "Bonjour ! Comment puis-je vous aider ?" }
  ],
  "stop_reason": "end_turn",
  "usage": { "input_tokens": 12, "output_tokens": 48 }
}

Le champ stop_reason dit pourquoi la réponse s’arrête : end_turn (terminée), max_tokens (tronquée par la limite demandée) ou refusal (refus par un filtre de sécurité). On le contrôle avant d’utiliser le texte.

Stocker la clé : Named Credential

La clé ne doit apparaître ni dans le code, ni dans une métadonnée personnalisée lisible par tous. Salesforce fournit pour cela le couple External Credential et Named Credential.

  1. External Credential Anthropic_API, protocole d’authentification Custom. Créer un principal (par exemple Anthropic) avec un paramètre d’authentification ApiKey contenant la clé.
  2. Named Credential Anthropic_API : URL https://api.anthropic.com, liée à l’External Credential. Décocher la génération de l’en-tête d’autorisation et cocher l’autorisation des formules dans les en-têtes HTTP.
  3. Sur l’External Credential, ajouter un en-tête personnalisé qui injecte la clé :
Nom de l'en-tête : x-api-key
Valeur           : {!$Credential.Anthropic_API.ApiKey}
  1. Permission set : donner l’accès au principal de l’External Credential aux utilisateurs (ou à l’utilisateur d’intégration) qui déclenchent l’appel.

Avec une Named Credential, aucun Remote Site Setting n’est nécessaire, et la clé peut changer sans redéploiement.

La classe ClaudeClient

Une classe courte, sans dépendance, qui construit la requête, appelle l’API et transforme les erreurs en exception typée. Elle indique si l’erreur est temporaire, ce qui permet de décider d’une nouvelle tentative.

public with sharing class ClaudeClient {

    public class ClaudeException extends Exception {
        public Integer statusCode;
        public Boolean retryable = false;
    }

    // Named Credential : l'URL de base et la clé restent hors du code.
    private static final String ENDPOINT = 'callout:Anthropic_API/v1/messages';
    private static final String MODEL = 'claude-opus-5-5';

    public static String complete(String systemPrompt, String userText, Integer maxTokens) {
        Map<String, Object> body = new Map<String, Object>{
            'model' => MODEL,
            'max_tokens' => maxTokens,
            'system' => systemPrompt,
            // Effort bas : réponse plus rapide, pour rester sous la limite de 120 s d'un callout.
            'output_config' => new Map<String, Object>{ 'effort' => 'low' },
            // En cas de refus par un filtre de sécurité, l'API rejoue la requête sur un modèle adapté.
            'fallbacks' => 'default',
            'messages' => new List<Object>{
                new Map<String, Object>{ 'role' => 'user', 'content' => userText }
            }
        };

        HttpRequest req = new HttpRequest();
        req.setEndpoint(ENDPOINT);
        req.setMethod('POST');
        req.setHeader('content-type', 'application/json');
        req.setHeader('anthropic-version', '2023-06-01');
        req.setHeader('anthropic-beta', 'server-side-fallback-2026-07-01');
        req.setTimeout(120000);
        req.setBody(JSON.serialize(body));

        HttpResponse res = new Http().send(req);
        if (res.getStatusCode() != 200) {
            throw buildError(res.getStatusCode(), res.getBody());
        }

        Map<String, Object> payload = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
        String stopReason = (String) payload.get('stop_reason');
        if (stopReason == 'refusal') {
            throw new ClaudeException('Demande refusée par le modèle.');
        }

        // La réponse peut commencer par un bloc "thinking" : ne garder que les blocs "text".
        List<String> parts = new List<String>();
        for (Object item : (List<Object>) payload.get('content')) {
            Map<String, Object> block = (Map<String, Object>) item;
            if ((String) block.get('type') == 'text') {
                parts.add((String) block.get('text'));
            }
        }
        if (stopReason == 'max_tokens') {
            parts.add(' […]');
        }
        return String.join(parts, '');
    }

    private static ClaudeException buildError(Integer status, String responseBody) {
        String message = 'HTTP ' + status;
        try {
            Map<String, Object> payload = (Map<String, Object>) JSON.deserializeUntyped(responseBody);
            Map<String, Object> apiError = (Map<String, Object>) payload.get('error');
            message += ' ' + apiError.get('type') + ' : ' + apiError.get('message');
        } catch (Exception parseError) {
            // Corps non JSON (proxy, page d'erreur) : le code HTTP suffit.
        }
        ClaudeException ex = new ClaudeException(message);
        ex.statusCode = status;
        // 429 (limite de débit), 500 et 529 (surcharge) sont temporaires ; 4xx restants : corriger la requête.
        ex.retryable = status == 429 || status >= 500;
        return ex;
    }
}

Quelques choix à noter :

  • Modèle claude-opus-5-5 avec un effort low : la qualité suffit largement pour un résumé, et la réponse arrive vite. Un callout Apex est limité à 120 secondes.
  • fallbacks: "default" (avec l’en-tête bêta associé) : si un filtre de sécurité refuse la demande, l’API la rejoue d’elle-même sur un modèle adapté, sans code supplémentaire.
  • Erreurs : 429, 500 et 529 sont temporaires (limite de débit, incident, surcharge) ; 400, 401, 403, 404 et 413 demandent de corriger la requête ou la configuration, les réessayer ne sert à rien.

Appeler en asynchrone

Un trigger ne peut pas faire de callout directement, et l’utilisateur n’a pas à attendre la réponse. Un Queueable avec Database.AllowsCallouts fait l’appel en arrière-plan, puis met à jour la demande.

public with sharing class CaseSummaryJob implements Queueable, Database.AllowsCallouts {

    private static final Integer MAX_ATTEMPTS = 3;
    private final Id caseId;
    private final Integer attempt;

    public CaseSummaryJob(Id caseId) {
        this(caseId, 1);
    }

    private CaseSummaryJob(Id caseId, Integer attempt) {
        this.caseId = caseId;
        this.attempt = attempt;
    }

    public void execute(QueueableContext context) {
        Case record = [
            SELECT Id, Subject, Description
            FROM Case
            WHERE Id = :caseId
            WITH USER_MODE
        ];
        try {
            String summary = ClaudeClient.complete(
                'Tu résumes une demande de support en trois phrases, en français, sans inventer d\'information.',
                'Sujet : ' + record.Subject + '\n\n' + record.Description,
                1024
            );
            update as user new Case(Id = record.Id, AI_Summary__c = summary);
        } catch (ClaudeClient.ClaudeException e) {
            if (e.retryable && attempt < MAX_ATTEMPTS) {
                // Nouvelle tentative différée (en minutes) plutôt qu'une boucle d'attente.
                System.enqueueJob(new CaseSummaryJob(caseId, attempt + 1), attempt * 2);
            } else {
                throw e;
            }
        }
    }
}
// Depuis un trigger after insert, un bouton ou un flow (action Apex) :
System.enqueueJob(new CaseSummaryJob(caseId));

En cas d’erreur temporaire, le job se replanifie avec un délai croissant (2 puis 4 minutes), trois tentatives au plus. WITH USER_MODE et update as user appliquent les droits de l’utilisateur qui a lancé le traitement.

Les tests

Les tests Apex ne peuvent pas appeler un service externe : HttpCalloutMock simule la réponse. On vérifie le cas nominal (blocs thinking ignorés), une erreur temporaire et une erreur définitive.

@IsTest
private class ClaudeClientTest {

    private class MockResponse implements HttpCalloutMock {
        private final Integer status;
        private final String body;

        MockResponse(Integer status, String body) {
            this.status = status;
            this.body = body;
        }

        public HttpResponse respond(HttpRequest req) {
            Assert.areEqual('2023-06-01', req.getHeader('anthropic-version'));
            HttpResponse res = new HttpResponse();
            res.setStatusCode(status);
            res.setBody(body);
            return res;
        }
    }

    @IsTest
    static void returnsOnlyTextBlocks() {
        Test.setMock(HttpCalloutMock.class, new MockResponse(200,
            '{"stop_reason":"end_turn","content":['
            + '{"type":"thinking","thinking":"","signature":"x"},'
            + '{"type":"text","text":"Résumé de test."}]}'));

        Test.startTest();
        String result = ClaudeClient.complete('Consigne', 'Texte', 256);
        Test.stopTest();

        Assert.areEqual('Résumé de test.', result);
    }

    @IsTest
    static void marksRateLimitAsRetryable() {
        Test.setMock(HttpCalloutMock.class, new MockResponse(429,
            '{"type":"error","error":{"type":"rate_limit_error","message":"Rate limited"}}'));

        try {
            ClaudeClient.complete('Consigne', 'Texte', 256);
            Assert.fail('Une ClaudeException était attendue.');
        } catch (ClaudeClient.ClaudeException e) {
            Assert.areEqual(429, e.statusCode);
            Assert.isTrue(e.retryable);
        }
    }

    @IsTest
    static void doesNotRetryAuthenticationError() {
        Test.setMock(HttpCalloutMock.class, new MockResponse(401,
            '{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}'));

        try {
            ClaudeClient.complete('Consigne', 'Texte', 256);
            Assert.fail('Une ClaudeException était attendue.');
        } catch (ClaudeClient.ClaudeException e) {
            Assert.isFalse(e.retryable);
        }
    }
}

Limites et bonnes pratiques

  • Temps et taille : 120 secondes par callout, et un tas de 12 Mo en asynchrone. Garder des réponses courtes (max_tokens adapté au besoin) et des entrées raisonnables.
  • Données personnelles : le texte envoyé quitte Salesforce. Vérifier le cadre contractuel et le RGPD, et n’envoyer que les champs utiles.
  • Volume : pour des milliers d’enregistrements, préférer un Batch Apex et surveiller les limites de débit de l’API et de l’org.
  • Coûts : l’API facture au jeton. Le champ usage de la réponse donne les jetons consommés ; le journaliser aide à suivre la dépense.
  • Contrôle humain : un résumé généré reste une aide. L’afficher comme tel, et ne pas l’utiliser seul pour une décision.
🍪 Gestion des cookies
Ce site utilise des cookies pour mesurer l'audience (Google Analytics) et lire des vidéos (YouTube). Vous pouvez personnaliser votre choix.
Mentions légales
Réglages des cookies
Essentiels Nécessaires au fonctionnement du site — ne peuvent pas être désactivés.
Google Analytics Analyse d'audience (pages vues, sources de trafic). Aucune donnée personnelle identifiante.
YouTube Permet de lire les vidéos YouTube intégrées sur cette page.