URL da Codex API sem o /v1: retrospectiva de um problema confundido com rede do campus e SSH reverso

Uma revisão do incidente em que a URL da API do Codex ficou sem /v1 e isso acabou sendo atribuído à rede da universidade e ao SSH reverso

O problema desta vez pode ser resumido em uma única frase:

Na configuração do Codex no Windows do meu amigo, o base_url da API compatível com a OpenAI foi escrito como https://botcf.com, faltando o prefixo padrão da API, /v1.
O caminho correto deveria ser fazer o Codex acessar rotas de API do tipo https://botcf.com/v1/....

Mas o tempo que realmente gastamos foi muito maior do que “olhar a configuração e perceber”. No meio do caminho, demos uma volta enorme: primeiro suspeitamos de proxy, rede da universidade, IPv6, conexões persistentes, Windows OpenSSH, porta ocupada e SSH reverso; depois, via TeamViewer, ficamos olhando logs, capturando pacotes e mudando a entrada SSH repetidamente na máquina dele. No fim, a causa raiz real era o erro mais simples possível: o caminho estava errado.

Este texto registra o processo completo e também serve como lembrete para mim mesmo: ao diagnosticar falhas em clientes de IA remotos, a primeira prioridade não é deixar a topologia de rede bonita, mas sim fazer primeiro uma verificação mínima do contrato da API.

Contexto

O cenário era mais ou menos este:

  • Um amigo estava rodando o Codex no Windows.
  • O upstream da API era um serviço compatível com a OpenAI que nós mesmos mantemos.
  • O Codex dele não conseguia concluir as requisições normalmente; parecia que a resposta em streaming caía muito rápido.
  • No começo, eu não conseguia operar diretamente o computador dele; só podia controlar via TeamViewer e colar comandos e logs no PowerShell.
  • Para que eu pudesse diagnosticar diretamente, depois ainda montamos um SSH reverso do Windows dele para o meu Mac, permitindo que eu, a partir da minha máquina, conectasse de volta ao Windows OpenSSH dele via 127.0.0.1:<porta>.

No fim, confirmamos que a configuração principal era:

model_provider = "custom"
model = "gpt-5.5"

[model_providers.custom]
wire_api = "responses"
requires_openai_auth = true
base_url = "https://botcf.com"   # Aqui faltou /v1

O problema está escondido justamente nesta última linha.

Primeira etapa: verificar primeiro se o Windows OpenSSH realmente tem uma entrada

No começo, estávamos olhando o estado do Windows OpenSSH. Em OpenSSH/Operational, basicamente só havia:

sshd: Server listening on 0.0.0.0 port 22.
sshd: Server listening on :: port 22.

No meio também havia uma linha de uma conexão externa com banner exchange ... Connection aborted, mas não havia logs de autenticação que explicassem a falha do Codex. Ou seja, o serviço estava em execução, mas só olhando esse log não dava para saber “por que o Codex não funciona”.

Depois, verificamos a escuta da porta 22 na máquina local:

Get-NetTCPConnection -LocalPort 22 -State Listen |
  Select-Object LocalAddress,LocalPort,OwningProcess

Aqui apareceu a primeira distração: não havia apenas OpenSSH na mesma porta. No Windows, 0.0.0.0:22 / :::22 era o sshd, mas 127.0.0.1:22 / ::1:22 estavam, por incrível que pareça, ocupados pelo Steam++.

Esse fenômeno é muito fácil de te levar para o caminho errado. Você acha que está testando o sshd do Windows, mas, na verdade, o loopback local pode primeiro cair em outro processo. Depois de fechar o Steam++, ssh 127.0.0.1 finalmente entrou no verdadeiro Windows OpenSSH.

A lição aqui é: no Windows, não basta ver se o sshd listening; também é preciso olhar o OwningProcess correspondente a cada LocalAddress. Especialmente porque 0.0.0.0 e 127.0.0.1 podem não ser o mesmo processo.

Segunda etapa: suspeitando da rede do campus e do IPv6, começamos a capturar pacotes

Como meu amigo estava em um ambiente de rede de campus, naturalmente suspeitamos:

  • Será que a rede do campus bloqueia SSH de entrada?
  • Dá para acessar via IPv6 a partir de fora?
  • Será que conexões longas são cortadas pelo gateway?
  • Será que um proxy ou o caminho pela Cloudflare afeta SSE?

O ipconfig na máquina do meu amigo mostrava que o adaptador PPP tinha um IPv4 público e um IPv6 global, e que o gateway padrão era um link-local IPv6. Fizemos uma rodada de captura com PowerShell e pktmon, e conseguimos ver tráfego na porta 22 saindo do endereço IPv6 da rede do campus para o endereço IPv6 do meu Mac, com ACK / payload.

Isso mostra que pelo menos o caminho “ele iniciar um SSH do computador dele até a minha máquina” estava funcionando. Ou seja, se não fizermos conexão de entrada e, em vez disso, ele se conectar ativamente a mim para então abrir um encaminhamento reverso de porta sobre SSH, isso é viável.

Na verdade, aqui já dava para chegar a uma conclusão na camada de rede: a rede do campus talvez não seja adequada para permitir que alguém de fora acesse diretamente a máquina dele, mas isso não impede que ele inicie conexões para fora. Então a solução mudou de “eu faço SSH para a máquina dele” para “ele faz SSH para a minha máquina e encaminha reversamente o sshd local dele para mim”.

Terceira etapa: SSH reverso, tropeçando em portas e host key pelo caminho

A ideia do SSH reverso é:

sshd do Windows do amigo: 127.0.0.1:22 ou [::1]:22
        ^
        | exposto via SSH -R
        |
127.0.0.1:2223 / 2224 no meu Mac

Ou seja, peça para o amigo executar algo como:

ssh -6 `
  -o PubkeyAuthentication=no `
  -o PreferredAuthentications=password `
  -o ExitOnForwardFailure=yes `
  -o ServerAliveInterval=30 `
  -o ServerAliveCountMax=3 `
  -N `
  -R 127.0.0.1:2224:[::1]:22 `
  ```<my-user>@[meu endereço IPv6]

No meio do caminho, esbarrei em alguns pequenos problemas.

O primeiro foi um conflito na porta de escuta remota:

Error: remote port forwarding failed for listen port 2222

Normalmente isso significa que a porta 2222 já está ocupada do meu lado, ou que algum encaminhamento anterior ainda não foi encerrado direito. Troquei para 2223 / 2224 e continuei.

O segundo foi uma confusão com a host key local no Windows. Quando meu amigo executou ssh 127.0.0.1 na máquina dele, apareceu:

WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!
Offending ED25519 key in ... known_hosts

Em princípio, o comando correto seria:

ssh-keygen -R 127.0.0.1

Mas o known_hosts dele tinha algumas linhas com formatação quebrada no final, fazendo com que o ssh-keygen -R se recusasse a modificar o arquivo:

known_hosts is not a valid known_hosts file.
Not replacing existing known_hosts file because of errors

No fim, simplesmente fizemos backup do known_hosts antigo e o movemos para fora do caminho, deixando o SSH registrar a host key novamente.

O terceiro foi o login por senha. A conta do Windows antes mostrava “não precisa de senha”, mas o login por senha via SSH exige uma senha real. Então, criamos temporariamente uma senha de curto prazo para depuração. Ao publicar um artigo sobre isso, não se deve manter a senha concreta; basta lembrar: depois que a assistência remota terminar, esse tipo de senha temporária deve ser removido ou alterado a tempo. O ideal, no fim das contas, é trocar para login por chave pública.

Por fim, o encaminhamento reverso funcionou. O log principal foi:

Autenticado em [meu endereço IPv6] usando "password".
Conexões remotas de 127.0.0.1:2224 encaminhadas para o endereço local ::1:22
encaminhamento remoto bem-sucedido para: escutar em 127.0.0.1:2224, conectar a ::1:22
forwarding_success: todas as respostas de encaminhamento esperadas foram recebidas

Depois, deste lado, posso usar:

ssh -p 2224 \
  -i ~/.ssh/<key> \
  -o HostKeyAlias=<friend-windows-via-reverse-tunnel> \
  <windows-user>@127.0.0.1

Entre diretamente no OpenSSH do Windows do amigo.

Quarta etapa: só depois de realmente acessar a máquina é que começamos a investigar o Codex

Depois de entrar via SSH reverso, ainda encontramos um pequeno problema: o shell remoto padrão do Windows não é um shell do tipo Unix. No começo, usamos:

hostname; uname -a; whoami; pwd

Esse conjunto de comandos era interpretado de forma bem estranha pelo shell do Windows. Depois mudamos para o PowerShell e, para evitar que várias camadas de aspas quebrassem o script, usamos -EncodedCommand ou enviamos o script do PowerShell via stdin.

As informações básicas obtidas incluíam:

  • Windows 11
  • O Codex vinha do caminho de instalação global do npm
  • codex-cli 0.133.0
  • Node v24.16.0
  • npm 11.13.0
  • O diretório de configuração ficava em %USERPROFILE%\.codex
  • Havia uma API key em auth.json
  • Os logs continuavam sendo gravados, indicando que não era um caso de “o cliente nem conseguiu iniciar”

Em seguida, lemos o config.toml; o conteúdo principal era:

model_provider = "custom"
model = "gpt-5.5"
model_reasoning_effort = "medium"
disable_response_storage = true

[model_providers.custom]
name = "custom"
wire_api = "responses"
requires_openai_auth = true
base_url = "https://botcf.com"
experimental_bearer_token = "<redacted>"

À primeira vista, o que mais chama a atenção é base_url.

Nossa configuração normal sempre foi algo parecido com:

base_url = "https://botcf.com/v1"

Mas, na hora, não percebemos isso de imediato. Porque antes tínhamos acabado de passar por rede do campus, IPv6, Steam++ ocupando porta, linha quebrada em known_hosts, encaminhamento reverso caindo; o cérebro já tinha sido levado pela narrativa de “problema de rede”.

Quinta etapa: cravar o problema com uma requisição HTTP mínima

O que realmente cravou o problema foi um teste HTTP na mesma máquina.

Primeiro, DNS e TCP:

Resolve-DnsName botcf.com
Test-NetConnection botcf.com -Port 443

O resultado mostrou que o DNS resolvia, a porta 443 conectava, e o endereço de origem saía pelo gateway IPv6 da rede do campus. A rede estava basicamente funcionando.

Depois, comparamos dois caminhos:

GET https://botcf.com/models
GET https://botcf.com/v1/models

A diferença era crucial:

  • https://botcf.com/models retornava 200 OK, mas o Content-Type era text/html; na prática, era a página de frontend do New API.
  • https://botcf.com/v1/models retornava 200 OK, com Content-Type application/json; esse sim era a API.

Isso explica por que o Codex se comportava como se a “resposta em streaming tivesse sido interrompida”:

O Codex estava configurado com:

base_url = "https://botcf.com"
wire_api = "responses"

Então ele tentava acessar algo como:

POST https://botcf.com/responses

Mas esse caminho não é uma API compatível com a OpenAI. O servidor pode retornar HTML, 404, redirecionamento ou algum tipo de resposta de roteamento do front-end. O Codex espera um JSON da Responses API / fluxo de eventos SSE, mas acaba recebendo algo que não segue esse protocolo; por isso, nos logs aparece algo como:

stream disconnected before completion

Na época, também testamos https://botcf.com/v1/responses e acabamos vendo outra mensagem de erro confusa:

{"error":{"message":"URL inválida (POST /v1/v1/responses)"}}

Essa mensagem por um tempo me levou na direção de “será que o upstream do servidor adicionou mais um /v1 por engano?”. Mas, olhando de novo, esse desvio não deveria se sobrepor às evidências principais:

  1. A configuração do cliente realmente estava sem /v1.
  2. A diferença de tipo de conteúdo entre /models e /v1/models já mostrava que a rota raiz e a rota da API não eram a mesma coisa.
  3. Depois que o usuário corrigiu a URL, o problema foi resolvido, o que mostra que a primeira coisa a corrigir era o API base do cliente.

O ponto aqui não é que “todos os servidores necessariamente só diferem por /v1”, e sim: ao encontrar um problema em um cliente OpenAI-compatible, é preciso usar uma requisição mínima para verificar se a URL real montada pela configuração atual é de fato a API.

Por que fomos desviados do caminho

Revendo esse troubleshooting, o maior desperdício não foi falta de capacidade técnica, mas o fato de a narrativa do problema ter nos condicionado desde o início.

1. Os sintomas pareciam demais com problema de rede

“Resposta em streaming cai muito rápido”, “Codex tenta de novo várias vezes”, “rede universitária”, “IPv6”, “proxy”, “Cloudflare” — essas palavras juntas naturalmente fazem a gente suspeitar de conexão persistente e rota de rede.

Mas, em clientes OpenAI-compatible, muitos erros de protocolo também se disfarçam de erro de rede. Por exemplo:

  • HTML sendo lido como SSE;
  • JSON 404 sendo lido como stream;
  • proxy reverso retornando uma página de login;
  • faltando um segmento no base path;
  • upstream montando o path errado de novo.

No fim, o cliente só sabe que “não recebeu um evento de conclusão válido”, então o erro exibido parece uma interrupção de stream.

2. Primeiro resolvemos “como controlar a máquina”, em vez de “para onde a requisição está realmente indo”

O controle remoto era realmente necessário, mas ele não deveria substituir o diagnóstico mínimo.

O primeiro passo mais rápido, na verdade, deveria ter sido pedir para a outra pessoa rodar no PowerShell:

curl.exe -i https://botcf.com/models
curl.exe -i https://botcf.com/v1/models

Basta ver que o primeiro é HTML e o segundo é JSON para suspeitar imediatamente do base_url.

3. SSH reverso é tecnicamente atraente demais

SSH reverso é uma boa ferramenta e, de fato, nos ajudou a finalmente acessar a máquina. Mas ele facilmente nos coloca em um modo de engenharia de “fazer o túnel funcionar”: porta, host key, senha, chave pública, IPv6, endereço de escuta, 127.0.0.1 vs ::1 — cada um desses pontos pode se desdobrar em vários outros.

Todos esses problemas são reais, mas não são a causa raiz.

Nesses momentos, é preciso lembrar: o canal de controle serve apenas para obter evidências, não é o objetivo em si. Assim que o canal estiver funcionando, volte imediatamente às evidências mínimas: configuração, requisição, resposta, logs.

Este SSH reverso ainda deixou experiências úteis

Embora a causa raiz não fosse a rede, o processo de SSH reverso desta vez não foi em vão. Ele deixou pelo menos alguns aprendizados práticos.

No Windows, a porta 22 depende do endereço específico

Não basta olhar apenas se “a 22 está escutando”; é preciso ver:

Get-NetTCPConnection -LocalPort 22 -State Listen |
  Select-Object LocalAddress,LocalPort,OwningProcess

Depois use:

Get-Process -Id<pid> | Select-Object Id,ProcessName,Path

Confirme se 127.0.0.1:22, ::1:22, 0.0.0.0:22 e :::22 são de fato o mesmo sshd.

Dê prioridade a vincular o encaminhamento reverso ao localhost remoto

Se for só para eu me conectar a partir do próprio Mac, não é preciso expor a porta à internet pública:

-R 127.0.0.1:2224:[::1]:22

Isso é mais seguro do que -R 0.0.0.0:2224:.... No servidor, só a própria máquina consegue se conectar a essa porta reversa.

Teste tanto 127.0.0.1 quanto ::1

No Windows, é comum o comportamento do loopback IPv4 e do loopback IPv6 não ser consistente, especialmente quando outro software também está ocupando a porta.

Desta vez, o que acabou ficando mais estável foi:

127.0.0.1:2224 -> [::1]:22

Se a host key bagunçou, não force

Se ssh-keygen -R falhar porque o arquivo known_hosts está corrompido, faça primeiro um backup do arquivo antigo e depois recrie-o. Isso é mais confiável do que continuar editando manualmente um arquivo danificado.

Senhas temporárias precisam ter ciclo de vida

É compreensível habilitar login por senha e definir uma senha temporária para resolver problemas, mas esse não é o estado final. Depois que a assistência remota terminar, você deve:

  • voltar para uma senha forte ou desativar o login por senha;
  • adicionar a chave pública;
  • remover encaminhamentos reversos desnecessários;
  • verificar o authorized_keys e a configuração do serviço SSH;
  • se você colou logs, confirmar que não houve vazamento de key / token.

Da próxima vez que eu encontrar um problema parecido, vou investigar nesta ordem

Depois desta vez, vou padronizar a primeira rodada de verificações de falhas em clientes OpenAI-compatible / Codex assim.

1. Primeiro ler a configuração

Foco em:

model_provider
model
wire_api
base_url
requires_openai_auth

Especialmente base_url:

  • OpenAI-compatible geralmente deve incluir /v1;
  • não tente adivinhar pelo domínio;
  • não se baseie apenas no fato de a página inicial do serviço abrir;
  • não misture o painel de administração, a página inicial do frontend e a base da API.

2. Use a mesma máquina e a mesma chave para fazer uma solicitação mínima

Teste pelo menos:

curl -i "$BASE/models"
curl -i "$BASE/responses"

Se não tiver certeza da base, teste também:

curl -i "https://example.com/models"
curl -i "https://example.com/v1/models"

Observe três coisas:

  • código de status HTTP;
  • Content-Type;
  • se o body é JSON ou HTML.

Se /models retornar HTML, não continue discutindo uma conexão longa SSE. O caminho está errado.

3. Depois, verifique os logs do cliente

O stream disconnected before completion nos logs é muito útil, mas não é a causa raiz. Ele apenas diz que “o cliente não leu o evento de conclusão esperado pelo protocolo”.

A próxima pergunta deve ser:

  • Qual foi o path realmente solicitado?
  • A resposta é SSE?
  • response.completed nela?
  • Foi recebida uma página de login, um JSON 404 ou uma página frontend em HTML?

4. Só então amplie para o caminho de rede

Somente se a requisição mínima da API ainda estiver anormal na URL correta, investigue:

  • DNS;
  • IPv4 / IPv6;
  • Cloudflare;
  • rede do campus;
  • proxy;
  • MTU;
  • conexão longa;
  • firewall;
  • upstream do servidor.

Essa ordem evita muito trabalho desnecessário.

Resumo

O ponto mais dramático desta vez foi: por causa de um erro de configuração em que faltou escrever /v1, acabamos verificando, um após o outro, permissões de administrador do Windows, logs do OpenSSH, portas em escuta, ocupação pelo Steam++, IPv6 da rede do campus, captura de pacotes com pktmon, corrupção do known_hosts, login por senha, SSH reverso, aspas no PowerShell, origem da instalação do Codex e logs.

Nenhum desses passos foi sem sentido; todos poderiam ter sido problemas reais. Mas nenhum deles era a causa raiz desta vez.

O que realmente deveria ter sido perguntado primeiro era:

A URL final montada por esse cliente é uma URL de API compatível com OpenAI?

Se no primeiro minuto tivéssemos executado:```text
GET New API → HTML
GET https://botcf.com/v1/models → JSON


Provavelmente não vamos gastar tempo com rede do campus e SSH reverso.

A experiência que isso me trouxe foi:

1. Em diagnósticos remotos complexos, primeiro valide o contrato do protocolo, depois valide o canal de rede.
2. “Conseguir conectar ao domínio” não é o mesmo que “conectar à API”.
3. Erros de streaming não são necessariamente problemas de conexão persistente; também podem ser causados por um caminho errado, fazendo com que nem exista streaming.
4. SSH reverso é uma ótima ferramenta para “cirurgia” remota, mas a ferramenta em si não pode virar o foco principal do diagnóstico.
5. Em serviços compatíveis com OpenAI, o `base_url` deve ser um item de verificação de primeiro nível, especialmente o `/v1`.

No fim, esse problema é meio engraçado e também muito real: duas pessoas ficaram um tempão olhando para proxy, rede do campus e conexão persistente, e no fim faltavam quatro caracteres na configuração.

`/v1`, quatro caracteres, suficientes para comprar uma noite inteira de diagnóstico.

Parece que resolvemos um dilema do ovo e da galinha

No fim, depois de fazer todo um malabarismo para entrar via SSH, ainda usamos a própria API que ele precisava usar para resolver o problema de essa API não funcionar

No nível da morte fingida do Zhongli :thinking: