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.
- POST
/{orgSlug}/{customUrl}/v1/messages - POST
/{orgSlug}/{customUrl}/v1/messages/count_tokens - POST
/{customUrl}/v1/messages - POST
/{customUrl}/v1/messages/count_tokens - POST
/v1/messages - POST
/v1/messages/count_tokens - HEAD
/ - HEAD
/{customUrl} - HEAD
/{customUrl}/v1/messages - HEAD
/{orgSlug}/{customUrl} - HEAD
/{orgSlug}/{customUrl}/v1/messages
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.
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" }]
}'
{
"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.
| Segmento | O que é | Como resolver |
|---|---|---|
orgSlug | O 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. |
customUrl | A 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.
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" }] }'
{
"error": "Ambiguous custom URL",
"message": "A URL /minha-url existe em mais de uma organização. Use /{orgSlug}/minha-url/v1/messages."
}
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.
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" }] }'
{
"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.
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" }]
}'
{"input_tokens": 12}
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.
- HEAD
/ - HEAD
/{customUrl} - HEAD
/{customUrl}/v1/messages - HEAD
/{orgSlug}/{customUrl} - HEAD
/{orgSlug}/{customUrl}/v1/messages
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.
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 enviar | Cabeçalho | Exemplo |
|---|---|---|
| Header da Anthropic | x-api-key | x-api-key: sk-lb-… |
| Header padrão | Authorization: Bearer | Authorization: Bearer sk-lb-… |
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:
ORG_SUSPENDED.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.
| Status | Causa | O que fazer |
|---|---|---|
| 400 | Chamada 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. |
| 401 | Sem 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. |
| 403 | Config 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. |
| 404 | customUrl não existe. | Confira o slug da organização e o nome da URL. Organização inexistente responde igual a config inexistente. |
| 409 | O mesmo customUrl existe em mais de uma organização, na rota legada. | Migre para /orgSlug/customUrl/v1/messages. |
| 429 | Todas 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. |
| 503 | Todas 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. |
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.
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" }]
}'
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.
-
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-Aftertraz 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)." } } -
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
codeestá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." } } -
409 — rota legada ambígua
A rota sem o slug da org só funciona enquanto o
customUrlnão repetir entre organizações. Quando repete, a resposta é 409, e o corpo sai no formato simpleserror+messageno topo — não no envelopetype: errorda 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:
key_error.getNextKeyExcludingProvider, porque Codex Responses tem path e body diferentes de /v1/messages.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: