ZAP-API
PreçosCasos de UsoBlogDocsLogin
Começar grátis
  1. Blog
  2. Multi-provider LLM Adapter: escolha por tarefa, não por fornecedor
Arquitetura

Multi-provider LLM Adapter: escolha por tarefa, não por fornecedor

Pattern de adapter aplicado a LLMs (Claude, Gemini, OpenAI). Trocar provider em uma linha, mapeamento por tarefa, prompt caching e quando NÃO usar a abstração.

06 de junho de 2026·12 min de leitura·Equipe Editorial ZAP API

Toda integração séria com LLM em produção encontra a mesma encruzilhada cedo ou tarde: você começou com OpenAI porque a doc era melhor, depois descobriu que Claude escreve melhor em português, depois leu que Gemini 2.0 Flash é dramaticamente mais barato para volume, e agora seu código tem três SDKs misturados, cada feature acoplada a um provider, e a próxima migração vai custar uma semana de refactor.

A solução é um pattern simples mas que pouca gente aplica de verdade: um LLM Adapter — uma interface comum entre providers que abstrai o SDK concreto. Cada provider implementa a mesma interface; o resto do código fala só com a interface. Trocar OpenAI por Claude vira uma mudança em um arquivo de factory, não refactor de fluxo.

Este artigo descreve o adapter que rodamos em produção na ZAP API. O código real é mais enxuto do que parece — ~200 linhas no total. O que importa é o pattern, e como mapear provider/modelo para cada tipo de tarefa.

O problema: lock-in implícito

Quando você importa from "openai" e chama openai.chat.completions.create({...}) em 30 pontos do código, você não está usando "uma LLM" — você está acoplado ao SDK específico, ao formato de mensagens específico, ao response shape específico. Migrar para outro provider exige:

  • Trocar o SDK e reescrever cada chamada
  • Adaptar o formato de mensagens (system/user/assistant vs role+content vs prompt simples)
  • Tratar parsing de resposta diferente (choices[0].message.content vs content[0].text vs candidates[0].content)
  • Reimplementar streaming, tool use, response_format/jsonMode em cada lugar

Se você tem 5 features usando LLM, são 5 migrações. Com Adapter, é uma só.

A interface mínima

// types.ts
export type LlmProfile =
  | "anthropic-sonnet"
  | "anthropic-haiku"
  | "gemini-flash"
  | "openai-mini"
  | "openai-advanced";

export interface LlmCompletionOptions {
  systemPrompt: string;
  userPrompt: string;
  jsonMode?: boolean;
  temperature?: number;
  maxTokens?: number;
  cacheSystemPrompt?: boolean; // Anthropic-specific, no-op em outros
}

export interface LlmAdapter {
  readonly providerName: "anthropic" | "gemini" | "openai";
  readonly modelName: string;
  complete(opts: LlmCompletionOptions): Promise;
}

Notou o detalhe? complete() retorna Promise<string>, não Promise<ResponseObject>. O caller é responsável por parsear (JSON.parse + validação de shape se for jsonMode). Por quê? Porque o response object de cada provider é diferente. Forçar uma estrutura comum ou (a) achata e perde info, ou (b) é mais complexo do que precisa para o uso comum (90% dos casos só querem o texto).

Profile lógico, não modelo concreto

O segundo ponto crítico: o resto do código nunca pede "claude-sonnet-4-5" ou "gpt-4o" diretamente. Pede um profile lógico — uma intenção:

const result = await llmAdapter("anthropic-sonnet").complete({
  systemPrompt: "Você é redator técnico...",
  userPrompt: "Escreva um artigo sobre...",
  cacheSystemPrompt: true,
});

O profile anthropic-sonnet mapeia, no factory.ts, para o modelo concreto e o adapter certo. Se amanhã sair Sonnet 4.6 e quisermos migrar tudo, é uma linha no factory. Se Anthropic raise pricing e quisermos testar Gemini 2.5 Pro como fallback, mudamos o mapping de um profile e o resto do código nem percebe.

// factory.ts
const PROFILE_TO_CONFIG = {
  "anthropic-sonnet": { adapter: "anthropic", model: "claude-sonnet-4-5" },
  "anthropic-haiku":  { adapter: "anthropic", model: "claude-haiku-4-5" },
  "gemini-flash":     { adapter: "gemini",    model: "gemini-2.0-flash" },
  "openai-mini":      { adapter: "openai",    model: "gpt-4o-mini" },
  "openai-advanced":  { adapter: "openai",    model: "gpt-4o" },
};

export function getLlmAdapter(profile: LlmProfile): LlmAdapter {
  const config = PROFILE_TO_CONFIG[profile];
  switch (config.adapter) {
    case "anthropic": return new AnthropicAdapter(config.model);
    case "gemini":    return new GeminiAdapter(config.model);
    case "openai":    return new OpenAIAdapter(config.model);
  }
}

Implementação de cada adapter

AnthropicAdapter

import Anthropic from "@anthropic-ai/sdk";

export class AnthropicAdapter implements LlmAdapter {
  readonly providerName = "anthropic" as const;
  private client: Anthropic;

  constructor(public readonly modelName: string) {
    const apiKey = process.env.ANTHROPIC_API_KEY;
    if (!apiKey) throw new LlmProviderNotConfiguredError("anthropic");
    this.client = new Anthropic({ apiKey });
  }

  async complete(opts: LlmCompletionOptions): Promise {
    const systemBlocks = opts.cacheSystemPrompt
      ? [{ type: "text" as const, text: opts.systemPrompt, cache_control: { type: "ephemeral" as const } }]
      : opts.systemPrompt;

    const response = await this.client.messages.create({
      model: this.modelName,
      max_tokens: opts.maxTokens ?? 4096,
      temperature: opts.temperature ?? 0.4,
      system: systemBlocks,
      messages: [{ role: "user", content: opts.userPrompt }],
    });

    const textBlock = response.content.find(b => b.type === "text");
    if (!textBlock || textBlock.type !== "text") {
      throw new Error("Anthropic response had no text block");
    }
    return textBlock.text;
  }
}

GeminiAdapter

import { GoogleGenerativeAI } from "@google/generative-ai";

export class GeminiAdapter implements LlmAdapter {
  readonly providerName = "gemini" as const;
  private client: GoogleGenerativeAI;

  constructor(public readonly modelName: string) {
    const apiKey = process.env.GEMINI_API_KEY;
    if (!apiKey) throw new LlmProviderNotConfiguredError("gemini");
    this.client = new GoogleGenerativeAI(apiKey);
  }

  async complete(opts: LlmCompletionOptions): Promise {
    const model = this.client.getGenerativeModel({
      model: this.modelName,
      systemInstruction: opts.systemPrompt,
      generationConfig: {
        temperature: opts.temperature ?? 0.4,
        maxOutputTokens: opts.maxTokens ?? 8192,
        ...(opts.jsonMode ? { responseMimeType: "application/json" } : {}),
      },
    });
    const result = await model.generateContent(opts.userPrompt);
    return result.response.text();
  }
}

OpenAIAdapter

import OpenAI from "openai";

export class OpenAIAdapter implements LlmAdapter {
  readonly providerName = "openai" as const;
  private client: OpenAI;

  constructor(public readonly modelName: string) {
    const apiKey = process.env.OPENAI_API_KEY;
    if (!apiKey) throw new LlmProviderNotConfiguredError("openai");
    this.client = new OpenAI({ apiKey });
  }

  async complete(opts: LlmCompletionOptions): Promise {
    const response = await this.client.chat.completions.create({
      model: this.modelName,
      temperature: opts.temperature ?? 0.4,
      max_tokens: opts.maxTokens ?? 4096,
      ...(opts.jsonMode ? { response_format: { type: "json_object" } } : {}),
      messages: [
        { role: "system", content: opts.systemPrompt },
        { role: "user", content: opts.userPrompt },
      ],
    });
    const content = response.choices[0]?.message?.content;
    if (!content) throw new Error("OpenAI response had no content");
    return content;
  }
}

Note que cacheSystemPrompt só é usado pelo AnthropicAdapter. Gemini e OpenAI ignoram silenciosamente — caller passa o flag e funciona em qualquer provider, com vantagem real só onde faz sentido. Princípio do pattern: caller não precisa saber qual provider está embaixo.

Esse padrão é aplicável a qualquer serviço de fronteira externa — não só LLMs. Email (Resend/SendGrid/SES), pagamento (Stripe/Adyen/Cielo), storage (S3/R2/B2). Sempre que houver mais de um vendor capaz de fazer o mesmo trabalho, abstraia desde o dia 1.

Mapeamento por tarefa, não por preferência

O ponto que mais separa código mediano de código bom: cada tarefa LLM tem o modelo certo. Não é "tudo Sonnet porque Sonnet é o melhor" — Sonnet é o melhor para algumas coisas e desperdício de dinheiro para outras.

TarefaProfilePor quê
Prosa longa em pt-BRanthropic-sonnetMelhor controle de tom, menos americanismos
JSON estruturado (outline, scoring)anthropic-haikuAcerta shape com consistência, custo baixo
Volume com contexto grandegemini-flash2M tokens de contexto, preço agressivo
Function calling complexoopenai-advancedEcosistema maduro, schema validation robusto
Fallback / compat legacyopenai-miniSempre disponível, predictable behavior

Importante: essa tabela é nossa, baseada em testes blind e métricas internas. Não copie cegamente. Faça seu próprio benchmark com 10 prompts do seu uso real e meça: qualidade de output, latência p95, custo por 1k tokens, taxa de retry por output malformado. O profile certo para o SEU produto pode ser diferente.

Prompt caching: o detalhe que economiza 90%

Anthropic suporta prompt caching desde 2026: você marca um bloco do prompt como cacheável com cache_control: { type: "ephemeral" }. Nas chamadas seguintes com o mesmo prefix, dentro de 5 minutos, o input cost daquele bloco cai ~90%.

Onde isso brilha:

  • System prompt grande e estável — regras de tom, exemplos few-shot, instruções de output. Tipicamente >1024 tokens.
  • Retry de uma mesma chamada — quando validação falha e você regenera. Sem cache, paga input full duas vezes. Com cache, segunda chamada é praticamente grátis no system.
  • Pipeline com múltiplos agentes usando contexto compartilhado — se 3 agentes usam o mesmo bloco de "contexto do produto" no system, cache transforma 3 cobranças em 1.

Mínimo para ativar: bloco precisa ter ≥1024 tokens. Abaixo disso, ignorado (mas sem erro). TTL de 5 minutos — depois disso, cache expira e próxima chamada paga full.

Para mais sobre quando faz sentido aplicar, leia também como combinamos prompt caching com background job assíncrono no pipeline editorial.

Erros estruturados: separa config de runtime

export class LlmProviderNotConfiguredError extends Error {
  constructor(public readonly provider: string) {
    super(`LLM provider "${provider}" not configured — set API key in env`);
    this.name = "LlmProviderNotConfiguredError";
  }
}

Provider sem env key configurada não é erro de runtime — é erro de configuração. Caller pode tratar separadamente:

try {
  const result = await llmAdapter("gemini-flash").complete(opts);
} catch (err) {
  if (err instanceof LlmProviderNotConfiguredError) {
    // Fallback para outro provider, ou erro 503 explícito
    return llmAdapter("anthropic-haiku").complete(opts);
  }
  throw err; // Erro de rede/quota/auth — propagar
}

Fallback automático é tentador mas perigoso: se Gemini falha por quota e cai pra Anthropic, você acaba pagando Anthropic full price sem perceber porque o admin "esqueceu" de configurar o orçamento Gemini. Use fallback explícito, com log de warning, não silencioso.

Quer ver esse adapter em produção? Os artigos do blog ZAP API são gerados por esse pipeline. Crie sua conta grátis e veja o painel super-admin onde a infraestrutura roda — 7 dias de trial sem cartão.

Quando NÃO usar o adapter

Como qualquer abstração, tem custo. Não vale a pena se:

  • Você usa LLM em 1-2 lugares só — adapter é overhead para chamada única. Acopla diretamente e migre depois se precisar.
  • Você precisa de features específicas de um provider — streaming tool use do OpenAI, vision com bounding boxes do Gemini, etc. Abstração comum não cobre tudo, e forçar pode quebrar a feature exata que você precisa.
  • Você está em fase de exploração — antes de decidir o que ficar, viva com o SDK nativo. Abstrai cedo demais e você acopla a uma forma errada de fazer.

Regra prática: comece direto com SDK do provider escolhido. Quando chegar a 3 lugares usando LLM, ou quando começar a sentir que vai migrar, abstraia. Antes disso, é YAGNI.

FAQ — LlmAdapter multi-provider

Por que não usar LangChain / LlamaIndex / Vercel AI SDK?

Essas libs resolvem o mesmo problema com mais features (memory, retrievers, agents, etc.). Trade-off: dependência mais pesada, ciclo de release rápido (breaking changes frequentes), abstração às vezes vaza (você precisa entender o internal pra debugar). Para um pipeline focado em completions estruturadas, ~200 linhas de adapter próprio dão mais controle. Se você precisa de RAG complexo, agents com tools, ou orquestração de fluxo agentic, vale considerar essas libs.

Como medir custo de cada profile em produção?

Cada adapter loga {provider, model, inputTokens, outputTokens, cached, durationMs} ao final de cada chamada. Você agrega isso por feature (qual rota disparou) e cruza com a tabela de pricing do provider. Em volume sério, vale construir um dashboard simples com Grafana ou similar para alertar quando custo de uma feature foge da média.

O que acontece se um provider muda o pricing no meio do mês?

Você tem três opções: (1) absorver — se margem permite; (2) migrar de profile — trocar anthropic-sonnet para gemini-pro em uma linha do factory, se qualidade tolerada; (3) repassar para o usuário final — só faz sentido se o uso de LLM é cobrado por unidade. Cenário 2 só é viável se você tem adapter — sem ele, opção 1 e 3 são as únicas reais.

Streaming funciona com esse padrão?

O padrão básico mostrado aqui é completion (espera resposta completa). Para streaming, a interface precisa mudar — em vez de retornar Promise<string>, retorna AsyncIterable<string> ou similar. Cada adapter implementa o stream do provider e traduz para o tipo comum. Mais complexo, mas mesmo princípio. Aplicável se você está construindo chat com UX de "digitando..." em tempo real.

Dá para usar com modelos open-source local (Llama, Mistral)?

Sim — basta um OllamaAdapter ou VllmAdapter implementando a mesma interface. Ollama/vLLM expõem API compatível com OpenAI Chat Completions, então o adapter quase replica o OpenAIAdapter com baseURL diferente. Útil em ambientes com requisitos de soberania de dados ou para reduzir custo em volume massivo.

E se eu quiser logar prompts/respostas para debug?

Adicione um LoggingAdapter que decora qualquer outro adapter — recebe um LlmAdapter no constructor, intercepta complete(), loga input/output, passa adiante. Pattern decorator clássico. Útil em desenvolvimento, ative com env flag em produção (cuidado com PII no log).

Vocês têm benchmark formal Sonnet vs GPT-4o vs Gemini 2.0?

Não publicamos benchmark formal porque os modelos mudam rápido — número de hoje fica desatualizado em 3 meses. Para benchmarks rigorosos, recomendamos LMSYS Chatbot Arena, HELM (Stanford) e Anthropic Evaluation Suite. Para o seu uso, faça benchmark próprio com 10-20 prompts do seu domínio real — vale mais que qualquer leaderboard genérico.

Construa em cima de uma API que não depende de um único fornecedor crítico. A ZAP API usa o mesmo princípio para gateway WhatsApp, email transacional e pagamento. Trial 7 dias grátis — sem cartão, sem aprovação.

Experimente a ZAP API gratuitamente

7 dias de trial sem precisar de cartão. A partir de R$29/mês*.

Criar instância grátis
EE
Equipe Editorial ZAP APIRevisão técnica

Desenvolvedores e especialistas em integrações WhatsApp. Todo conteúdo passa por revisão técnica para garantir precisão e aplicabilidade.

Ver perfil completoDocumentaçãoTrial grátis

Leia também

Arquitetura · 13 de mai. de 2026 · 14 min

Outbox pattern no WhatsApp: garantia de entrega sem perda de mensagem

Como implementar o Outbox Pattern para garantir que mensagens WhatsApp sejam entregues mesmo quando o gateway está fora, com retry automático e zero perda.

Arquitetura · 05 de jun. de 2026 · 14 min

Pipeline editorial IA multi-agente em produção: 5 agentes, ~85% menos custo

Como rodamos um pipeline editorial multi-agente em produção: 5 agentes Claude especializados, paralelização, prompt caching e background job. Arquitetura replicável.

Arquitetura · 07 de jun. de 2026 · 12 min

Radar SEO automatizado: descobrindo oportunidades sem analista contratado

Como montar um radar SEO automatizado com SerpAPI, Google Ads Keyword Planner, LLM scoring e cache Redis. Pipeline de 4 camadas, custo ~$50/mês, adaptável a qualquer nicho.

Arquitetura · 30 de mai. de 2026 · 13 min

Mensagem idempotente no WhatsApp: nunca envie a mesma mensagem duas vezes

Como implementar idempotência em disparos WhatsApp para evitar mensagens duplicadas em retries, falhas de rede e race conditions em sistemas distribuídos.

Tópicos:Chatbots com IAE-commerceAPI WhatsApp

Explore também

Casos de usoWhatsApp API por segmentoComparativoZAP API vs alternativasPreçosPlanos e o que está inclusoGlossárioTermos técnicos de WhatsApp API
ZAP-API

API REST para WhatsApp com webhooks assinados, Meta Pixel/CAPI e compliance LGPD. Sem aprovação da Meta.

Status operacional🇧🇷 Feito no Brasil

Produto

  • Preços
  • Casos de uso
  • Comparativo
  • Trial grátis
  • Dashboard

Recursos

  • Documentação
  • Blog
  • Glossário
  • RSS Feed

Empresa

  • Sobre
  • Imprensa
  • Termos de uso
  • Privacidade
  • Criar conta
  • Login

Contato

  • [email protected]
  • [email protected]
  • Resposta em até 24h úteis
© 2026 ZAP-API — Todos os direitos reservados·CNPJ 42.130.949/0001-56·Termos·Privacidade

Desenvolvido por PreviusIA