Documentação do app v1.0

API do proxy

O proxy é a superfície que o seu cliente fala. Aqui estão as rotas que ele expõe, a chave que autentica a chamada, o que cada status de erro significa e o que muda quando você liga o streaming.

Rotas do proxy#

O proxy vive na raiz do host, sem prefixo. Todas as rotas estão em backend/src/routes/proxy/index.ts, e nenhuma delas usa prefix — cada caminho é escrito por extenso.

Só quatro rotas são públicas

Das seis rotas POST, apenas as quatro com customUrl na URL são públicas. POST /v1/messages e POST /v1/messages/count_tokens existem, mas são chamadas internas: o handler principal as invoca via fastify.inject() depois de autorizar a URL, e uma request direta é recusada com 400 e a mensagem Use a configured custom URL endpoint. O bloqueio é uma flag _proxyAuthorized que só o handler principal liga.

Forma ① — a org vem da URL#

Use /orgSlug/customUrl/v1/messages. O handler resolve a organização pelo slug e procura a config dentro dela. Organização inexistente responde igual a config inexistente — a URL pública não enumera organizações.

bash200 OK
curl https://lb.exemplo.com/minha-org/minha-url/v1/messages \
  -H "x-api-key: sk-lb-..." \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{
    "model": "claude-sonnet-4-5",
    "max_tokens": 1024,
    "messages": [{ "role": "user", "content": "oi" }]
  }' 
json
{
  "id": "msg_01ABC",
  "type": "message",
  "role": "assistant",
  "model": "claude-sonnet-4-5",
  "content": [{ "type": "text", "text": "Olá! Como posso ajudar?" }],
  "stop_reason": "end_turn",
  "usage": {
    "input_tokens": 12,
    "output_tokens": 8
  }
}

Parâmetros

Os dois segmentos da URL são obrigatórios e têm papéis diferentes.

SegmentoO que éComo resolver
orgSlugO slug da organização dona da URL.Cada organização tem um slug único (slug String @unique). Confira o slug vigente no painel: se a organização trocar de slug, a URL antiga para de resolver.
customUrlA URL do proxy, única entre as não nulas (@unique).Você escolhe ao criar a config em URL Configs. O alvo declarado no código é @@unique([organizationId, customUrl]) — a URL passa a ser única por organização —, mas enquanto a rota legada existir, a unicidade continua global.

O corpo é o da API Messages da Anthropic, sem campos novos. A config da URL ainda aplica o que ela define — systemPrompt, por exemplo — antes do corpo seguir para o provedor, e uma chave de provedor com model próprio sobrescreve o modelo pedido pelo cliente.

Forma ② — legada, sem o slug da org#

/customUrl/v1/messages resolve a config só pelo nome da URL. A rota existe, mas tem data de remoção declarada: 2026-12-31. Cada uso loga um warn no servidor — é esse log que mostra quem ainda não migrou.

bash409 Conflict
curl https://lb.exemplo.com/minha-url/v1/messages \
  -H "x-api-key: sk-lb-..." \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{ "model": "claude-sonnet-4-5", "max_tokens": 64, "messages": [{ "role": "user", "content": "oi" }] }' 
json
{
  "error": "Ambiguous custom URL",
  "message": "A URL /minha-url existe em mais de uma organização. Use /{orgSlug}/minha-url/v1/messages."
}
409 não é falha de configuração

O 409 só aparece quando o mesmo customUrl existe em duas organizações — e o @unique global de customUrl no banco é o que hoje impede essa colisão, tornando o 409 uma defesa do caminho legado. Enquanto não houver colisão, a forma legada continua funcionando — mas cada chamada escreve um aviso nos logs do servidor, e a data de remoção já está escrita no código.

Forma ③ — as rotas internas#

POST /v1/* e POST /v1/messages/count_tokens existem para o handler principal reaproveitar o mesmo código depois de autorizar a customUrl. Chamadas diretas continuam bloqueadas com 400 invalid_request_error enquanto a flag _proxyAuthorized não estiver ligada.

bash400 Bad Request
curl https://lb.exemplo.com/v1/messages \
  -H "x-api-key: sk-lb-..." \
  -H "content-type: application/json" \
  -d '{ "model": "claude-sonnet-4-5", "max_tokens": 64, "messages": [{ "role": "user", "content": "oi" }] }' 
json
{
  "type": "error",
  "error": {
    "type": "invalid_request_error",
    "message": "Use a configured custom URL endpoint."
  }
}

count_tokens#

A rota de contagem tem as mesmas duas formas — com e sem o slug da org — e devolve a contagem em vez da resposta do modelo. A contagem reflete o que a chave escolhida faria com o corpo: o override de model e os parâmetros de geração da chave são aplicados ao corpo antes da contagem, porque um modelo diferente usa um tokenizer diferente.

bash200 OK
curl https://lb.exemplo.com/minha-org/minha-url/v1/messages/count_tokens \
  -H "x-api-key: sk-lb-..." \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{
    "model": "claude-sonnet-4-5",
    "messages": [{ "role": "user", "content": "oi" }]
  }' 
json
{"input_tokens": 12}
Erros de contagem também viram 503 genérico

Quando o upstream recusa a contagem, o proxy responde 503 com overloaded_error e a mensagem All configured providers are unavailable. — o corpo do upstream não vaza. A chave que falhou recebe a mesma política de erro do caminho principal, mas count_tokens é um preflight: ele não entra no loop de failover, porque a chamada real que vem depois faz o failover dela. Quando a URL não tem nenhuma conta ativa, a resposta é 400 com uma mensagem que diz exatamente qual ajuste fazer no painel.

O HEAD de preflight#

Cinco caminhos respondem HEAD, e todos usam o mesmo handler, que devolve 200 com corpo vazio.

O handler existe por um motivo específico: o Claude Code, cujo cliente HTTP é escrito em Bun, dispara um HEAD / contra a ANTHROPIC_BASE_URL antes da primeira requisição real. Sem 200, o CLI trata o endpoint como indisponível e nem chega a enviar a requisição.

O Authorization está ausente de propósito — o preflight não exige chave nenhuma. O proxy autentica por chave de cliente validada dentro do handler real, nunca por sessão de admin. Nenhum header próprio é setado por este handler; os headers CORS vêm do plugin global, não do preflight.

O HEAD na raiz é o único com prazo

HEAD / e HEAD /customUrl não casam com PROXY_PATH_RE, que só reconhece /v1/ e …/v1/. Como HEAD também não está na lista de métodos de mutação, os dois caem no piso de MEMBER — um monoreador anônimo levaria 401 em vez do 200 pretendido. Isso está registrado como ponto para revisão: é leitura do guard, não verificado em runtime.

Autenticação#

Toda URL exige uma chave de acesso do Load Balance antes de qualquer processamento — pessoal (sua) ou de serviço (da empresa). A chave é extraída de x-api-key ou de Authorization: Bearer, e só conta valor que começa com sk-lb-: um Bearer sk-ant-oat01-… é o OAuth do próprio cliente, não uma chave do LB.

Onde enviarCabeçalhoExemplo
Header da Anthropicx-api-keyx-api-key: sk-lb-…
Header padrãoAuthorization: BearerAuthorization: Bearer sk-lb-…
A chave nunca chega ao provedor

Depois de autorizar, stripProxyKeyHeaders remove a chave sk-lb- dos headers da request. Isso importa porque headers.ts repassa Authorization e x-api-key em alguns caminhos — sem a remoção, a sua chave do LB viajaria até o provedor upstream.

A ordem de autorização é fixa, e ela explica a cascata de respostas. O handler resolve a config primeiro e falha em sequência:

  • Config não encontrada — 404.
  • Config sem organização — 403. O pool de chaves é por organização e não existe pool "geral" para ela cair.
  • Tenant ausente — 500. Falha fechado.
  • Chave de cliente — 401.
  • Contrato da empresa encerrado na Conta — 403 com ORG_SUSPENDED.
  • Remoção dos headers da chave e checagem da liberação do grupo compartilhado.
  • O 401 é o mesmo para chave ausente, chave inválida e URL que você não vê. Isso é deliberado: a resposta não diz qual URL existe, e a URL pública não pode ser enumerada.

    Erros#

    O proxy responde no envelope de erro da API Messages: type: error com error.type e error.message. Os bytes do upstream nunca chegam ao cliente — o corpo de mensagem do 429 e do 402 são as duas únicas exceções, porque neles o cliente pode agir.

    StatusCausaO que fazer
    400Chamada direta a /v1/* sem a flag de autorização, ou URL sem nenhuma conta ativa.Aponte o cliente para a URL com customUrl. Se o erro for o de conta inexistente, ligue a URL a um grupo com contas no painel.
    401Sem chave sk-lb-, chave inválida, ou URL invisível para você.Gere a chave em My account → Chaves de API e configure ANTHROPIC_API_KEY com ela.
    403Config sem organizationId, ou contrato da empresa encerrado na Conta.Para o ORG_SUSPENDED: nada foi revogado — as chaves voltam a valer quando a empresa contratar o load balancer de novo.
    404customUrl não existe.Confira o slug da organização e o nome da URL. Organização inexistente responde igual a config inexistente.
    409O mesmo customUrl existe em mais de uma organização, na rota legada.Migre para /orgSlug/customUrl/v1/messages.
    429Todas as contas da URL passaram do corte de uso, ou todas levaram 429.Respeite o Retry-After, que traz o reset real da janela. O corpo diz qual janela estourou e quando a conta volta.
    503Todas as chaves falharam, o pool está em cooldown, ou o upstream foi sanitizado.É a resposta terminal do failover. O corpo é genérico e não diz qual chave falhou.
    O 429 é o único erro com prazo real

    Quando todas as contas da URL estão esgotadas, o handler não toca no upstream: responde na hora com 429 e Retry-After calculado a partir do reset mais próximo. Já o 503 do failover não tem prazo — se o pool inteiro está em cooldown, o cliente precisa respeitar o próprio backoff.

    Streaming#

    Com "stream": true no corpo, a resposta vira text/event-stream. O proxy faz push-through: repassa os bytes ao cliente conforme chegam, sem bufferizar a resposta inteira.

    bash200 OK
    curl -N https://lb.exemplo.com/minha-org/minha-url/v1/messages \
      -H "x-api-key: sk-lb-..." \
      -H "anthropic-version: 2023-06-01" \
      -H "content-type: application/json" \
      -d '{
        "model": "claude-sonnet-4-5",
        "stream": true,
        "max_tokens": 1024,
        "messages": [{ "role": "user", "content": "oi" }]
      }' 
    text
    event: message_start
    data: {"type":"message_start","message":{"id":"msg_01ABC","model":"claude-sonnet-4-5","usage":{"input_tokens":12,"output_tokens":1}}}
    
    event: content_block_start
    data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
    
    event: content_block_delta
    data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Olá"}}
    
    event: message_delta
    data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":8}}
    
    event: message_stop
    data: {"type":"message_stop"}

    Só chegam ao cliente bytes que o parser de SSE reconheceu como frame válido — o coletor acumula em validBytes e só então faz push. Isso existe porque um upstream já injetou texto JSON error… cru dentro de uma stream SSE: o parser descartava em silêncio, mas o coletor repassa, e o cliente via o lixo antes do event: error final.

    O HEAD de stream é o ponto sem volta do protocolo. Depois do primeiro byte, o status HTTP não pode mais mudar, e por isso a escolha da chave precisa estar fechada antes. Erro que chega depois de dados válidos já repassados vira um frame event: error genérico seguido do encerramento — esconder esse erro é impossível em qualquer protocolo de streaming.

    O corpo do frame de erro nunca carrega a mensagem real do upstream: pode ser stack trace de TLS, erro de DNS com hostname interno, ou a string literal "aborted". O que chega é overloaded_error.

    Respostas especiais#

    Três respostas valem mais do que o status sugere, porque o cliente pode agir sobre elas.

    1. 429 — todas as contas esgotadas

      Quando toda conta do grupo passou do corte de 5h ou semanal, ou todas levaram 429, o handler responde na hora, sem tocar no upstream. O Retry-After traz o reset real da janela mais próxima, e o corpo diz qual janela estourou — 5 horas, semanal ou a do provedor — e quando a conta volta.

      json
      {
        "type": "error",
        "error": {
          "type": "rate_limit_error",
          "message": "Todas as contas desta URL atingiram o limite de 5 horas. A próxima volta em 2026-10-14 18:30 UTC (em 42 min)."
        }
      }
    2. 403 — empresa suspensa

      A chave é válida, mas o contrato da empresa foi encerrado na Conta. Nada foi revogado: a mesma chave volta a funcionar quando a empresa contratar o load balancer de novo. O code estável é o que permite ao cliente distinguir esse caso de chave inválida.

      json
      {
        "type": "error",
        "error": {
          "type": "permission_error",
          "code": "ORG_SUSPENDED",
          "message": "Contrato da empresa encerrado na Conta Nommand: as chaves de acesso desta empresa estão suspensas. Elas voltam a funcionar quando a empresa contratar o load balancer de novo."
        }
      }
    3. 409 — rota legada ambígua

      A rota sem o slug da org só funciona enquanto o customUrl não repetir entre organizações. Quando repete, a resposta é 409, e o corpo sai no formato simples error + message no topo — não no envelope type: error da API Messages. O 404 de config não encontrada e o 403 de config sem organização também usam esse formato simples.

      json
      {
        "error": "Ambiguous custom URL",
        "message": "A URL /minha-url existe em mais de uma organização. Use /{orgSlug}/minha-url/v1/messages."
      }

    O que o proxy faz antes de responder 503#

    Todo non-2xx do upstream é retentável entre chaves — não há status que escape disso. Antes de devolver 503, o proxy:

  • Captura o snapshot ordenado de chaves do grupo uma vez, antes de iterar, para um cooldown de 60 s que expira no meio do loop não reintroduzir uma chave já tentada.
  • Aplica a política de erro na chave que falhou — cooldown por 429 e por erro temporário, desativação por key_error.
  • Reprocessa o corpo do zero para a próxima chave, com o transform de provider dela. Uma chave de outro provider usa getNextKeyExcludingProvider, porque Codex Responses tem path e body diferentes de /v1/messages.
  • Devolve o 429 e o 402 ao cliente com uma mensagem acionável; qualquer outro terminal vira 503 genérico.
  • O teto de tentativas não é fixo: é uma tentativa por chave do snapshot, sem recycling. O tempo é limitado pelo dial de 180 s do upstream, e o loop verifica o desconexão do cliente a cada iteração.

    Sobre isso existe um same-error guard. Se o mesmo erro normalizado — status mais mensagem — aparece em três chaves distintas dentro da mesma requisição, o problema é a requisição, não as chaves. O guard marca a flag, reverte todas as desativações feitas naquela chamada e não sai do loop, para chegar ao 503 terminal sem ter derrubado metade do pool.

    Próximos passos#

    Entenda o que acontece dentro do pool antes de mexer nas rotas.

    Depois de dominar o básico, explore além:

    Documentação gerada com a skill docs-padrao.