# Fallback de LLM no Laravel: como trocar de Claude para OpenAI quando a API cai

> Como montar uma cadeia de fallback entre Claude e OpenAI no Laravel: quais erros disparam a troca, orçamento de tempo, circuit breaker, prompts portáveis e testes.

Por Lucas Roberto · IA · publicado em 2026-10-18 · 10 min de leitura
Versão web: https://lucasroberto.com/blog/fallback-de-llm-no-laravel-como-trocar-de-claude-para-openai-quando-a-api-cai

Para manter um agente de IA no ar quando a API do Claude devolve 529 ou estoura o timeout, coloque os provedores atrás de uma interface comum. Uma cadeia tenta o principal e, só em erros de disponibilidade, passa para o próximo (OpenAI, por exemplo), sempre dentro de um orçamento de tempo fixo. O código da troca é pequeno. O trabalho de verdade está em decidir quais erros justificam o fallback, em manter prompts e histórico num formato que os dois modelos entendam e em testar o caminho alternativo antes de precisar dele.

Eu cheguei nisso do jeito comum. Quem coloca LLM (modelo de linguagem grande, como Claude ou GPT) no fluxo principal de um produto, como um agente que responde clientes no WhatsApp ou um classificador que roda a cada transação, descobre rápido que "a API está sobrecarregada" vira incidente seu. O cliente não sabe qual provedor você usa. Ele só vê que o atendimento parou.

## Quando vale a pena ter fallback entre provedores de LLM?

Nem sempre vale. O fallback tem custo: um segundo contrato, um segundo conjunto de prompts para manter e um segundo modelo para avaliar. Uso estes critérios:

1. **A chamada está no caminho síncrono do usuário.** Um agente de atendimento, uma busca ou um assistente na tela precisam de resposta agora. Um job noturno que categoriza extrato pode só esperar e tentar de novo mais tarde.
2. **A tarefa é portável.** Classificação, extração de campos e respostas curtas com contexto funcionam bem em modelos diferentes. Um fluxo afinado por semanas para um modelo específico, com muitas ferramentas e regras sutis, degrada mais na troca.
3. **Você consegue avaliar o segundo modelo.** Se não existe um conjunto de casos para medir a qualidade do fallback, você está trocando indisponibilidade por resposta errada, e em fintech resposta errada costuma sair mais caro.

Se a tarefa não passa nesses três pontos, a melhor alternativa costuma ser uma fila com retry e um modo degradado bem pensado, sem segundo provedor.

## Quais erros da API devem disparar o fallback?

Esse é o ponto onde mais vejo implementação errada. Fallback em qualquer exceção esconde bug: um 400 por payload malformado vai falhar nos dois provedores e você paga duas vezes para descobrir isso. Eu separo assim:

| Situação | Exemplo | Fallback? |
|---|---|---|
| Sobrecarga do provedor | Anthropic `529 overloaded_error` | Sim |
| Rate limit | `429` | Sim, e respeito o `retry-after` no breaker |
| Erro interno | `500`, `502`, `503` | Sim |
| Timeout ou falha de conexão | `ConnectionException` do `Http::` | Sim, com cuidado com custo (ver adiante) |
| Credencial inválida | `401`, `403` | Sim, mas com alerta imediato: é configuração quebrada |
| Requisição inválida | `400`, contexto grande demais, `413` | Não. É bug seu e o outro provedor vai falhar também |
| Resposta válida, conteúdo ruim | JSON que não passa na validação | Não por padrão. Trato como erro de qualidade |

Na prática isso vira duas exceções: `ProviderUnavailable`, que autoriza tentar o próximo, e `LlmRequestFailed`, que sobe direto.

## Como abstrair Claude e OpenAI atrás de uma interface no Laravel?

O domínio não deve saber qual provedor respondeu. Ele manda um `LlmRequest` neutro e recebe um `LlmResponse` neutro. Chamo as APIs com o `Http::` do Laravel em vez de SDKs, porque assim controlo timeout, headers e o tratamento de erro do mesmo jeito nos dois lados.

```php
interface LlmProvider
{
    public function name(): string;
    public function complete(LlmRequest $request, float $timeout): LlmResponse;
}

final class LlmRequest
{
    public function __construct(
        public readonly string $system,
        public readonly array $messages, // [['role' => 'user', 'content' => '...']]
        public readonly int $maxTokens = 1024,
        public readonly float $budgetSeconds = 20.0,
    ) {}
}
```

O provedor da Anthropic, usando a Messages API:

```php
final class AnthropicProvider implements LlmProvider
{
    public function __construct(private string $model) {}

    public function name(): string { return 'anthropic'; }

    public function complete(LlmRequest $req, float $timeout): LlmResponse
    {
        try {
            $res = Http::withHeaders([
                'x-api-key' => config('services.anthropic.key'),
                'anthropic-version' => '2023-06-01',
            ])->connectTimeout(3)->timeout($timeout)
              ->post('https://api.anthropic.com/v1/messages', [
                  'model' => $this->model,
                  'max_tokens' => $req->maxTokens,
                  'system' => $req->system,
                  'messages' => $req->messages,
              ]);
        } catch (ConnectionException $e) {
            throw new ProviderUnavailable($this->name(), 'connection', $e);
        }

        ErrorClassifier::guard($this->name(), $res);

        $text = collect($res->json('content'))
            ->where('type', 'text')->pluck('text')->implode('');

        return new LlmResponse(
            text: $text,
            provider: $this->name(),
            model: $this->model,
            inputTokens: $res->json('usage.input_tokens'),
            outputTokens: $res->json('usage.output_tokens'),
        );
    }
}
```

O da OpenAI muda pouco. A Chat Completions API recebe o system prompt como primeira mensagem do array e devolve o texto em `choices[0].message.content`:

```php
$res = Http::withToken(config('services.openai.key'))
    ->connectTimeout(3)->timeout($timeout)
    ->post('https://api.openai.com/v1/chat/completions', [
        'model' => $this->model,
        'max_completion_tokens' => $req->maxTokens,
        'messages' => [
            ['role' => 'system', 'content' => $req->system],
            ...$req->messages,
        ],
    ]);

// texto: $res->json('choices.0.message.content')
// tokens: usage.prompt_tokens e usage.completion_tokens
```

O classificador de erro é compartilhado:

```php
final class ErrorClassifier
{
    public static function guard(string $provider, Response $res): void
    {
        if ($res->successful()) {
            return;
        }

        $status = $res->status();

        if ($status === 429 || $status >= 500 || in_array($status, [401, 403])) {
            throw new ProviderUnavailable($provider, "http_{$status}");
        }

        throw new LlmRequestFailed($provider, $status, $res->body());
    }
}
```

Os nomes dos modelos ficam em `config/services.php`, lidos do `.env`. Assim dá para trocar o modelo de fallback sem deploy de código.

## Como montar a cadeia de fallback com orçamento de tempo?

O erro clássico é dar 30 segundos de timeout para cada provedor. Se o Claude trava até o limite e depois a OpenAI também demora, o cliente no WhatsApp esperou um minuto. Por isso a cadeia trabalha com um **orçamento total**: cada tentativa recebe só o tempo que sobrou.

```php
final class LlmChain
{
    /** @param LlmProvider[] $providers */
    public function __construct(
        private array $providers,
        private ProviderBreaker $breaker,
    ) {}

    public function complete(LlmRequest $req): LlmResponse
    {
        $deadline = microtime(true) + $req->budgetSeconds;
        $errors = [];

        foreach ($this->providers as $provider) {
            $remaining = $deadline - microtime(true);

            if ($remaining < 2 || $this->breaker->isOpen($provider->name())) {
                continue;
            }

            try {
                $response = $provider->complete($req, $remaining);
                $this->breaker->success($provider->name());

                return $response;
            } catch (ProviderUnavailable $e) {
                $this->breaker->failure($provider->name());
                $errors[] = $e;
                Log::warning('llm.fallback', [
                    'provider' => $provider->name(),
                    'reason' => $e->reason,
                ]);
            }
        }

        throw new AllProvidersUnavailable($errors);
    }
}
```

A ordem dos provedores é decisão de produto. Às vezes o primeiro fallback deve ser **outro modelo do mesmo provedor**: um 529 pode afetar um modelo e não outro, e trocar de modelo dentro da Anthropic mantém o prompt e o formato de ferramentas. Eu costumo montar algo como: modelo principal, modelo menor do mesmo provedor, outro provedor. Quem já tem roteamento por complexidade, como descrevi em [como rotear chamadas entre Claude Haiku e Sonnet no Laravel](/blog/como-rotear-chamadas-entre-claude-haiku-e-sonnet-no-laravel-e-escalar-so-quando-precisa), já tem metade dessa estrutura pronta.

## Como evitar martelar um provedor que está fora?

Sem um circuit breaker, cada requisição vai bater no provedor fora do ar, esperar o timeout e só depois cair no fallback. Circuit breaker é o padrão que "abre o circuito" depois de N falhas seguidas e pula aquele destino por um tempo. Em incidente, ele transforma uma espera de 20 segundos em zero.

Uma versão mínima com Redis via `Cache`:

```php
final class ProviderBreaker
{
    public function isOpen(string $p): bool
    {
        return Cache::has("llm:breaker:open:{$p}");
    }

    public function failure(string $p): void
    {
        Cache::add("llm:breaker:fails:{$p}", 0, now()->addMinute());
        $fails = Cache::increment("llm:breaker:fails:{$p}");

        if ($fails >= 5) {
            Cache::put("llm:breaker:open:{$p}", true, now()->addSeconds(30));
        }
    }

    public function success(string $p): void
    {
        Cache::forget("llm:breaker:fails:{$p}");
    }
}
```

Quando o circuito fecha depois dos 30 segundos, a próxima requisição funciona como sonda. Os detalhes de meio-aberto, janela deslizante e o que fazer com muitos workers concorrendo estão no post sobre [circuit breaker no Laravel com Redis](/blog/circuit-breaker-no-laravel-com-redis-como-isolar-um-gateway-de-pagamento-fora-do-ar). Lá o exemplo é um gateway de pagamento, mas a mecânica é a mesma.

## Fallback dentro de job: como não multiplicar retries e custo?

Quando a cadeia roda dentro de um job de fila, existem três camadas que podem repetir a chamada: o `$tries` do job, um eventual `Http::retry()` e a própria cadeia. Multiplicando, 3 tentativas do job vezes 2 provedores vezes 2 retries HTTP dão até 12 chamadas pagas para uma única mensagem. A regra que sigo:

1. **Nada de `Http::retry()` dentro do provedor.** Quem decide repetir é a cadeia.
2. **O job só tenta de novo quando recebe `AllProvidersUnavailable`**, com backoff de segundos, não de milissegundos.
3. **`LlmRequestFailed` falha o job na hora** (`$this->fail($e)`), porque repetir um 400 não resolve nada.

Tem também o problema do timeout que não foi falha. Se o Claude processou a requisição, mas a resposta não chegou dentro do seu limite, você paga essa chamada e depois paga a do fallback. Não existe idempotência que evite isso do lado do provedor de LLM. O que dá para fazer é calibrar o timeout pela latência real (p99 do seu uso, não um número redondo) e contar essas ocorrências. Escrevi mais sobre esse tipo de duplicação em [como chamar LLMs no Laravel com filas, retries e timeouts sem duplicar custo](/blog/como-chamar-llms-no-laravel-com-filas-retries-e-timeouts-sem-duplicar-custo).

Outro custo que some na troca é o prompt caching, o recurso que cobra menos pelos tokens de um prefixo de prompt repetido. O cache de um provedor não vale no outro, e os preços por token são diferentes. Se o fallback assumir uma fatia relevante do tráfego durante um incidente longo, a conta muda. Vale simular os dois cenários na [calculadora de custo de IA](/ferramentas/calculadora-custo-ia) antes de definir o modelo de reserva.

## O prompt funciona igual no outro modelo?

Não. Os modelos seguem instruções de jeitos diferentes, e o mesmo system prompt pode gerar respostas mais longas, mais formais ou que ignoram uma regra. O que funciona para mim:

- **Um prompt por provedor, com a mesma intenção.** Versiono os dois juntos e, quando mudo uma regra de negócio, mudo nos dois no mesmo PR.
- **A validação da saída é o contrato, não o modelo.** Se a resposta precisa ser um JSON com categoria e valor, o `Validator` do Laravel valida a resposta dos dois provedores com as mesmas regras. Quem falhar na validação é tratado igual.
- **Histórico em formato neutro.** Esse é o ponto que mais pega em agentes. Se a conversa começou no Claude com blocos `tool_use` e `tool_result` e, no meio dela, o fallback assume, a OpenAI espera `tool_calls` dentro da mensagem do assistente e mensagens com `role: tool`. Se você guarda o payload bruto do provedor no banco, não consegue trocar no meio da conversa. Eu guardo mensagens e chamadas de ferramenta numa estrutura própria (papel, texto, nome da ferramenta, argumentos, resultado) e cada provedor tem um tradutor.

As definições de ferramentas também precisam de tradução, mas aqui o mapeamento é direto porque os dois usam JSON Schema:

```php
// Neutro: ['name' => ..., 'description' => ..., 'schema' => [...]]

$anthropic = fn (array $t) => [
    'name' => $t['name'],
    'description' => $t['description'],
    'input_schema' => $t['schema'],
];

$openai = fn (array $t) => [
    'type' => 'function',
    'function' => [
        'name' => $t['name'],
        'description' => $t['description'],
        'parameters' => $t['schema'],
    ],
];
```

Na volta, cuidado com um detalhe: o Claude entrega `input` já como objeto, enquanto a OpenAI entrega `function.arguments` como string JSON. Sempre faço `json_decode` e valido os argumentos antes de executar qualquer ferramenta, seja qual for o provedor.

## E a LGPD quando os dados vão para um segundo provedor?

Colocar um segundo provedor significa mais um operador recebendo dados pessoais dos seus clientes. Antes de ligar o fallback em produção, eu verifico:

1. O provedor de reserva está listado nos seus termos e na política de privacidade, e o encarregado de dados (DPO) sabe dele.
2. As condições de retenção e de uso dos dados para treinamento foram conferidas na conta de API daquele provedor.
3. A mesma minimização vale nos dois caminhos: se você mascara CPF e dados bancários antes de mandar para o Claude, o mascaramento tem que acontecer antes da cadeia, não dentro de um provedor só.

O terceiro ponto é o que mais escapa. A sanitização precisa ficar acima da abstração, senão o fallback vira um vazamento que só aparece em dia de incidente.

## Como testar o fallback com Http::fake?

O caminho alternativo é justamente o que nunca roda em desenvolvimento, então precisa de teste automatizado. Com `Http::fake` dá para simular o Claude sobrecarregado e a OpenAI respondendo:

```php
it('cai para a OpenAI quando o Claude está sobrecarregado', function () {
    Http::fake([
        'api.anthropic.com/*' => Http::response([
            'type' => 'error',
            'error' => ['type' => 'overloaded_error', 'message' => 'Overloaded'],
        ], 529),
        'api.openai.com/*' => Http::response([
            'choices' => [['message' => ['role' => 'assistant', 'content' => 'Oi!']]],
            'usage' => ['prompt_tokens' => 12, 'completion_tokens' => 3],
        ]),
    ]);

    $res = app(LlmChain::class)->complete(
        new LlmRequest('Você é um assistente.', [['role' => 'user', 'content' => 'Oi']])
    );

    expect($res->provider)->toBe('openai');
    Http::assertSentCount(2);
});
```

Escrevo também o teste inverso: um 400 no Claude não pode gerar nenhuma chamada para a OpenAI. Esse teste pega a regressão mais comum, que é alguém trocar `ProviderUnavailable` por `Throwable` no `catch`. Para a qualidade, rodo o mesmo conjunto de evals nos dois modelos e só aceito o fallback se o segundo passar no mínimo que defini para a tarefa.

## O que fazer quando todos os provedores falham?

Isso vai acontecer, nem que seja por falha de rede do seu lado. O `AllProvidersUnavailable` precisa de um destino pensado para cada caso de uso:

- **Agente no WhatsApp:** mensagem fixa e honesta ("estou com instabilidade, já te respondo"), mensagem original guardada para reprocessar e, se o assunto for sensível, transferência para um humano.
- **Classificação ou extração em background:** o job volta para a fila com backoff longo e o registro fica com status pendente, sem chute de categoria.
- **Funcionalidade na tela:** botão desabilitado com aviso, e o resto do sistema funcionando normalmente.

E observabilidade mínima: registre em toda resposta qual provedor e qual modelo atenderam, e crie um alerta para a taxa de fallback. Fallback alto por horas é sinal de que o principal está degradado ou de que uma credencial expirou, e você quer saber disso por um alerta, não pela fatura.

## Conclusão prática

Se for implementar na próxima sprint, eu seguiria esta ordem. Primeiro, a interface neutra e o classificador de erros, porque só isso já impede retry de 400. Depois, a cadeia com orçamento de tempo e o breaker no Redis. Em seguida, o histórico de conversa em formato próprio, se o caso for um agente com ferramentas. Por último, o segundo provedor, com prompt próprio, evals e o aval do jurídico sobre dados pessoais. Comece com o fallback ligado só na funcionalidade que mais dói quando para, acompanhe a taxa de troca por algumas semanas e só depois estenda para o resto. E faça um teste de verdade: derrube o provedor principal em staging, trocando a chave por uma inválida, e veja se o sistema se comporta como você planejou.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
