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_urlda API compatível com a OpenAI foi escrito comohttps://botcf.com, faltando o prefixo padrão da API,/v1.
O caminho correto deveria ser fazer o Codex acessar rotas de API do tipohttps://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/modelsretornava200 OK, mas oContent-Typeeratext/html; na prática, era a página de frontend do New API.https://botcf.com/v1/modelsretornava200 OK, comContent-Typeapplication/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:
- A configuração do cliente realmente estava sem
/v1. - A diferença de tipo de conteúdo entre
/modelse/v1/modelsjá mostrava que a rota raiz e a rota da API não eram a mesma coisa. - 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_keyse 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?
- Há
response.completednela? - 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.