Data plane
Cinco superfícies, um só pipeline de governança.
As rotas abaixo entram pela mesma porta: autenticação, permissões, orçamento e taxa, guardrails, roteamento e trilha valem para todas. O que muda é o protocolo de cada uma.
Completions
POST /v1/chat/completions aceita o corpo da OpenAI e o repassa ao provedor resolvido para o modelo, depois de aplicar os portões da organização. A resposta de sucesso é a do provedor — exceto pela máscara de PII que um guardrail de saída em modo mascarar aplica sobre choices[].message.content antes de o cliente, o cache e a trilha verem o corpo (ver Guardrails).
Streaming
Com "stream": true a resposta É text/event-stream: uma linha data: <json do chunk> por quadro e um data: [DONE] final. O x-avalon-request-id sai no primeiro byte, antes de qualquer quadro — é o que permite correlacionar um stream que termina mal. Guardrails de entrada rodam antes de qualquer byte sair: uma recusa sai como resposta HTTP comum, em JSON, e o cliente nunca chega a ver cabeçalho de stream nenhum.
Peça "stream_options": {"include_usage": true} para o quadro final de usage — sem ele o provedor normalmente não manda tokens, e a linha da trilha fica com consumo não medido.
246 e 446: os dois status que só existem aqui
| Status | Quando acontece |
|---|---|
246 | Sucesso COM AVISO: um guardrail em modo monitor (deny: false) falhou sem bloquear. O corpo é a completion do provedor, igual a um 200 — só o status muda. O detalhe de qual guardrail falhou fica na trilha, nunca no corpo. Embeddings roda guardrails de entrada como as completions, mas a rota sempre devolve o status cru do provedor (ou do cache) — nunca promove para 246, mesmo quando um guardrail de entrada em modo monitor falhou. |
446 | Um guardrail bloqueante recusou o conteúdo, na entrada ou na saída. Fora da faixa que os SDKs compatíveis com OpenAI interpretam, de propósito: nunca é confundido com sucesso parcial nem com erro do provedor. O corpo diz qual guardrail e qual check, nunca o conteúdo detectado. |
Embeddings
POST /v1/embeddings tem o mesmo pipeline de governança das completions — permissões, orçamento, taxa, roteamento, cache simples, trilha — para vetores de texto. As diferenças de contrato que valem ler antes de integrar:
inputaceita uma string ou um array de até 2048 strings (o mesmo teto da OpenAI). Array vazio, string vazia ou item vazio recusam400 input_invalido.encoding_formatsó aceita'float'; qualquer outro valor recusa400 encoding_nao_suportado.useralimentametadata._user, o mesmo campo que o braço do titular da LGPD usa para achar (e apagar) o que uma pessoa gerou.- Guardrails valem só na direção de entrada — a saída é um vetor numérico, sem texto. Um check de saída configurado não quebra a rota: é ignorado e anotado como
direcao_inaplicavelna trilha. - A trilha grava um resumo da resposta (
{object, itens, dims, usage}), nunca o vetor. O cliente recebe a resposta íntegra. Guardar 2048 × 1536 floats por linha custaria dezenas de MB para armazenar o que ninguém audita. - Não existe streaming no protocolo de embeddings:
streamestream_optionssão removidos do corpo antes de sair para o motor.
Prompt library
POST /v1/prompts/{referencia}/completions resolve a referência no workspace da chave, renderiza as mensagens da versão com variables (sintaxe Mustache, incluindo {{>partial}}) e entrega o resultado ao MESMO pipeline das completions — orçamento, taxa, guardrails, roteamento, streaming e trilha idênticos.
Campos do corpo além de variables são overrides, aplicados campo a campo sobre o que a versão define (model, temperature, e até messages). variables é vocabulário nosso e nunca atravessa para o provedor. A trilha marca a linha com metadata._prompt e, quando o corpo sobrescreveu as mensagens renderizadas, com metadata._prompt_override.
O prompt existe mas não renderiza? 422, com partial_nao_encontrado ou partial_ciclico — nada foi chamado no provedor. Referência inexistente, versão fora do range, label sem versão, ou prompt de outro workspace: os quatro respondem 404 prompt_nao_encontrado, igual, para que quem varre referências não aprenda o que existe.
Gateway MCP
Proxy governado de Model Context Protocol, no transporte Streamable HTTP. O corpo é uma requisição JSON-RPC 2.0 — initialize, tools/list, tools/call, ou um batch — e atravessa intacto para o servidor MCP cadastrado sob o slug. A resposta do upstream volta como veio, com uma allowlist estrita de cabeçalhos.
| Verbo | Papel |
|---|---|
POST /mcp/{slug} | A requisição JSON-RPC. |
GET /mcp/{slug} | O canal SSE por onde o servidor envia mensagens não solicitadas. O Last-Event-Id do cliente é repassado, para uma reconexão retomar de onde parou. |
DELETE /mcp/{slug} | Encerra a sessão identificada pelo Mcp-Session-Id. Um encerramento limpo costuma ser 204, sem corpo. |
Sessão
O Mcp-Session-Id vale nos dois sentidos: o que o servidor MCP emitir volta ao cliente, e o que o cliente mandar é repassado ao servidor. É isso que faz um SDK de MCP manter sessão através do gateway sem saber que ele existe. O Mcp-Protocol-Version do cliente também é repassado.
O que o gateway decide antes de repassar
- A chave precisa do escopo
mcp.invoke. - O servidor precisa estar PROVISIONADO para o workspace da chave — o gate real de alcance, mais forte que o escopo.
- A credencial do servidor é injetada por nós e nunca pode ser sobrescrita por um cabeçalho do cliente.
- Ferramenta fora da allowlist da organização é recusada no protocolo, não no HTTP:
200com envelope de erro JSON-RPC (code: -32603,data.codigo: ferramenta_nao_permitida). Derrubar a sessão de um cliente de SDK por política seria pior do que responder o que ele sabe ler. - Corpo de erro do upstream NUNCA é repassado (pode carregar credencial nossa) — vira
502 upstream_mcp_indisponivel.
Gateway A2A (agentes)
Proxy governado de A2A (Agent-to-Agent). O corpo é uma requisição JSON-RPC 2.0 e atravessa intacto para o agente cadastrado sob o slug — inclusive quando a resposta é streaming: message/stream e tasks/resubscribe respondem em SSE, e o relay é incremental (cada quadro sai para o cliente no instante em que chega do agente, com contrapressão — nunca bufferizado e despejado no fim).
Quickstart: apontar um agente existente para o gateway
Não há SDK novo a instalar. O cliente A2A troca a URL base do agente pela nossa e autentica com uma chave gov_ que tenha o escopo agent.invoke — o mesmo cabeçalho Authorization de sempre. O SDK resolve o card em GET /a2a/{slug}/.well-known/agent-card.json e passa a falar JSON-RPC em POST /a2a/{slug}.
# antes: o cliente fala direto com o agente
https://meu-agente.exemplo.com
# depois: mesma conversa, agora governada
https://api.avalonops.com.br/a2a/ag-meu-agente-3f8a1c
Authorization: Bearer gov_... # chave com escopo agent.invokeO endereço real do agente sai de supported_interfaces[] do card dele — o gateway nunca assume que a URL base é o endpoint RPC. E o card servido por nós é o do agente, como ele o publicou (com provider, signatures, ícone e extensões que nem lemos), filtrado pela política de skill do workspace da chave. A descoberta exige a MESMA chave e o MESMO escopo do POST: um card público seria um enumerador de slugs.
O que o gateway decide antes de repassar
- A chave precisa do escopo
agent.invoke. - O limite de taxa por requisições do workspace vale (com contagem própria). Orçamento não — uma chamada de agente não consome tokens.
- O agente precisa estar PROVISIONADO para o workspace da chave — o gate real de alcance, mais forte que o escopo. Slug inexistente e slug não provisionado devolvem o MESMO 404 servidor_agent_nao_encontrado, para que quem varre slugs não aprenda o que existe.
- A credencial do agente é injetada por nós e nunca vem do cliente; a chave gov_ do cliente nunca atravessa para o agente.
- Corpo de erro do upstream NUNCA é repassado (pode carregar credencial nossa) — vira
502 upstream_agent_indisponivel. Já um erro A2A do próprio agente (-32001 TaskNotFound) é resposta legítima do protocolo e volta crua, dentro de um200.
skillId é convenção da Avalon, opt-in — não é campo do protocolo
Declarada a skill e estando ela fora da allowlist do par agente×workspace, a recusa é no protocolo, não no HTTP: 200 com envelope de erro JSON-RPC (code: -32603, data.codigo: skill_nao_permitida), com o mesmo id que o cliente mandou. Derrubar a conversa de um SDK por política seria pior do que responder o que ele sabe ler. A mesma allowlist filtra skills[] do card servido àquele workspace.
Webhooks: mediação total
tasks/pushNotificationConfig/set e tasks/pushNotificationConfig/get são os ÚNICOS métodos interceptados (não repassados crus). No set, o gateway guarda a URL que o cliente configurou e registra no agente uma URL nossa, com um token opaco por task: o agente nunca conhece a URL do cliente, e o cliente nunca vê a nossa. O get responde com a config DO CLIENTE, sem consultar o agente (que só conhece a nossa).
- URL do cliente inválida (mesma política de SSRF do resto do produto) recusa
webhook_url_invalida— em envelope JSON-RPC, como a recusa de skill. A URL é validada duas vezes: ao gravar e de novo no instante da entrega (o DNS pode ter mudado). setsemparams.taskIdou sempushNotificationConfig.urlrecusawebhook_config_invalida.- QUALQUER lote JSON-RPC (um array no corpo) que contenha uma chamada
setougeté recusado inteiro (webhook_batch_nao_suportado), mesmo que essa seja a única chamada do lote — um array de um item só ainda é um array, e a mediação de webhook não sabe reescrever metade de um corpo que o cliente mandou como uma unidade. Mande osetsozinho, fora do lote. - Recebida a notificação em
POST /a2a/webhook/{token}, a entrega ao webhook do cliente sai assinada com HMAC SHA-256 do corpo no cabeçalhox-avalon-assinatura, com retentativas em 1s, 10s e 60s. O segredo é gerado no console e exibido uma única vez.
Rotacionar o segredo substitui o atual na hora, sem período de graça: as entregas ainda não enviadas passam a assinar com o segredo novo (ele é lido na hora de assinar, uma vez por tentativa). O que já saiu não é alcançado por rotação nenhuma.
Cabeçalhos
x-metadata: {"cliente":"acme","feature":"suporte"} # pares texto->texto na trilha
x-portkey-config: cfg_... # vence a config padrão da chave
x-portkey-cache-force-refresh: 1 # presença basta: ignora e regravax-avalon-request-id: <uuid> # o mesmo id da linha da trilha
x-avalon-retry-attempt-count: 2 # tentativas do roteamento antes do passo final
x-origem-da-falha: plataforma # ou "provedor", em toda resposta de erro
x-portkey-cache-status: HIT # HIT | MISS | REFRESH | DISABLED