Calling an External LLM from Apex: Named Credentials, Retries, and When Not To

The full-control path: any model, any endpoint — and every safety net is now your code. Part 3 of the External AI series.

The scenario

Vantage Components manufactures industrial sensors. Their support engineers triage failure reports that mix free text with cryptic diagnostic codes. The data science team fine-tuned an open-weight model on ten years of failure reports and hosts it on the company's own private cloud — behind their VPN, on their GPUs, at a fixed monthly cost. It classifies failure modes better than any frontier model they tested, because it has seen their diagnostic codes.

Self-hosted means no Einstein Studio connection type reaches it — BYO LLM (Part 2) registers models on supported providers, not arbitrary private endpoints. So this is a legitimate Path 3 case from the decision guide: a raw Apex callout. This post builds it properly — credentials, client class, retries, error taxonomy — and then spells out when you should not be on this path at all.

Step 1 — External Credential + Named Credential

Never put an API key in Apex, custom settings, custom metadata or labels. The platform's split model keeps the secret out of your code entirely:

Setup for Vantage's bearer-token endpoint (Setup → Named Credentials):

  1. Create an External Credential, authentication protocol Custom: name Vantage_LLM_Auth.
  2. Add a principal (e.g. LLM_Service) and an authentication parameter holding the token — call it ApiKey. The value is encrypted at rest and write-only after save.
  3. On the External Credential, add a custom header: name Authorization, value {!'Bearer ' & $Credential.Vantage_LLM_Auth.ApiKey} — the formula injects the secret at callout time.
  4. Create the Named Credential Vantage_LLM with URL https://llm.internal.vantage.example, referencing the External Credential. Enable "Generate Authorization Header" only if you're not building it yourself via the custom header — here we are, so leave it off.
  5. Grant access: add the External Credential's principal to a permission set (e.g. LLM Callout Access) and assign it to the integration users. No permission set, no callout — this is your first access-control layer.
Why not a plain endpoint + Remote Site Setting? Remote Site Settings only whitelist a URL; the secret still has to live somewhere in your org, readable by anyone who can read that somewhere. The External Credential model encrypts the secret, scopes it by permission set, and keeps it out of debug logs and version control. There is no good reason to skip it.

Step 2 — The client class

One class owns the callout. Everything else in the org calls this and nothing else — that discipline is what makes Part 5's masking and audit enforceable later.

public with sharing class VantageLlmClient {

    public class LlmException extends Exception {}
    public class LlmRateLimitException extends Exception {}

    private static final Integer TIMEOUT_MS   = 120000;
    private static final Integer MAX_ATTEMPTS = 3;

    public static String complete(String systemPrompt, String userPrompt) {
        Integer attempt = 0;
        while (true) {
            attempt++;
            HttpResponse res;
            try {
                res = new Http().send(buildRequest(systemPrompt, userPrompt));
            } catch (System.CalloutException e) {
                // Network failure / timeout — retryable
                if (attempt >= MAX_ATTEMPTS) {
                    throw new LlmException(
                        'LLM unreachable after ' + attempt + ' attempts: '
                        + e.getMessage());
                }
                continue;
            }

            Integer code = res.getStatusCode();
            if (code == 200) {
                return parseAnswer(res.getBody());
            }
            if (code == 429 || code >= 500) {
                // Transient: rate limit or server error — retryable
                if (attempt >= MAX_ATTEMPTS) {
                    throw new LlmRateLimitException(
                        'LLM returned ' + code + ' after '
                        + attempt + ' attempts');
                }
                continue;
            }
            // 4xx other than 429: OUR bug (bad auth, bad payload).
            // Retrying cannot fix it — fail loud, immediately.
            throw new LlmException('LLM rejected request: ' + code
                + ' ' + res.getBody().abbreviate(500));
        }
    }

    private static HttpRequest buildRequest(String systemPrompt,
                                            String userPrompt) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:Vantage_LLM/v1/chat/completions');
        req.setMethod('POST');
        req.setHeader('Content-Type', 'application/json');
        req.setTimeout(TIMEOUT_MS);
        req.setBody(JSON.serialize(new Map<String, Object>{
            'model'       => 'vantage-failure-triage',
            'temperature' => 0.2,
            'max_tokens'  => 400,
            'messages'    => new List<Object>{
                new Map<String, Object>{
                    'role' => 'system', 'content' => systemPrompt },
                new Map<String, Object>{
                    'role' => 'user',   'content' => userPrompt }
            }
        }));
        return req;
    }

    private static String parseAnswer(String responseBody) {
        Map<String, Object> parsed =
            (Map<String, Object>) JSON.deserializeUntyped(responseBody);
        List<Object> choices = (List<Object>) parsed.get('choices');
        if (choices == null || choices.isEmpty()) {
            throw new LlmException('LLM returned no choices: '
                + responseBody.abbreviate(500));
        }
        Map<String, Object> first =
            (Map<String, Object>) choices[0];
        Map<String, Object> message =
            (Map<String, Object>) first.get('message');
        return (String) message.get('content');
    }
}

Design decisions worth stealing:

Step 3 — Real backoff belongs in a Queueable

Apex has no sleep. The loop above retries immediately, which is fine for a blip but wrong for a genuinely rate-limited endpoint. For background work — Vantage classifies new failure reports on creation, nobody is watching a spinner — the retry with actual delay comes from re-enqueueing:

public class FailureTriageJob implements Queueable,
                                          Database.AllowsCallouts {
    private Id caseId;
    private Integer attempt;

    public FailureTriageJob(Id caseId, Integer attempt) {
        this.caseId  = caseId;
        this.attempt = attempt;
    }

    public void execute(QueueableContext ctx) {
        Case c = [SELECT Description FROM Case
                  WHERE Id = :caseId WITH USER_MODE];
        try {
            String label = VantageLlmClient.complete(
                'You are a failure-mode classifier. Answer with ' +
                'exactly one label from: SENSOR_DRIFT, SEAL_FAILURE, ' +
                'FIRMWARE, WIRING, UNKNOWN.',
                c.Description);
            update as user new Case(Id = caseId,
                                    Failure_Mode__c = label.trim());
        } catch (VantageLlmClient.LlmRateLimitException e) {
            // Real backoff: re-enqueue with a delay, up to 5 tries.
            if (attempt < 5) {
                System.enqueueJob(
                    new FailureTriageJob(caseId, attempt + 1),
                    attempt * 2); // delay in minutes: 2, 4, 6, 8
            } else {
                update as user new Case(Id = caseId,
                                        Failure_Mode__c = 'UNKNOWN');
            }
        }
    }
}

The enqueue-with-delay overload gives you minutes-scale backoff without burning synchronous limits, and the terminal fallback (UNKNOWN, not an unhandled exception) keeps the pipeline moving when the model is down. If the value your job writes back came from an LLM, validate it like user input — label.trim() here should really be checked against the allowed picklist values before the update; Part 5 makes that pattern explicit.

When this is the wrong choice

This path is legitimate for Vantage because no platform path can reach their model. Be suspicious of every other justification. You are on the wrong path if:

The honest cost of Path 3: the client class above is maybe a day of work. The masking, prompt-injection defenses, cost log and audit trail it still needs (Part 5) are a multiple of that — and they're not optional, they're the parts the trust layer would have given you.

Sources: Named Credentials and External Credentials — Salesforce Help · Named Credentials as Callout Endpoints — Apex Developer Guide · Queueable Apex — Apex Developer Guide