Pular para o conteúdo
Fantástico Mundo de Jon
RSS

Uma API inteira pelo chat: o que o GPT-4o acerta e onde ele te enrola

Em novembro de 2024 nenhum agente edita meus arquivos: a API sai do chat em blocos que eu colo à mão. O GPT-4o acerta a estrutura e me enrola em quatro lugares — o pior é o teste que passa sem testar nada.

Com pressa? Peça o TL;DR ao Claude — ele lê a página e resume.

O chat escreve a sua API inteira. Ele só nunca vai saber que ela existe.

Isso não é uma metáfora. Em novembro de 2024 não existe a coisa que abre o meu repositório, lê os arquivos, decide o que mudar, aplica, roda o teste e volta com o resultado. Existe o Cursor, que edita o arquivo que está aberto quando eu aceito a sugestão. Existe o Aider, que aplica o patch e commita o que eu mandei aplicar. Existe o Copilot completando a linha. Mas para construir uma coisa do zero, o fluxo que eu — e quase todo mundo que conheço — de fato uso é o mais burro possível: eu descrevo, o modelo devolve um bloco, eu colo no editor, rodo, e volto com o erro colado de novo.

Copiar e colar não é um detalhe do método. É o método.

O laço é sempre igual. Eu descrevo, ele devolve, eu colo, o TypeScript reclama, eu copio o erro de volta, ele conserta aquela linha e reintroduz outra coisa dois blocos acima. Quem fecha o ciclo sou eu, uma rodada por vez, com a área de transferência. E o detalhe que explica metade dos problemas deste post: o modelo nunca vê o resultado de rodar o que escreveu — ele vê a minha transcrição do resultado, filtrada pelo que eu achei relevante colar.

Fiz isso do começo ao fim numa API REST de pedidos. Veredito curto: o GPT-4o acerta o que tem forma previsível e me enrola em quatro lugares. Nos quatro, o erro só aparece depois de colar.

O que foi construído · novembro de 2024

Tarefa
API REST de pedidos, 4 endpoints
Stack
Node 20 · TypeScript 5.6 · Fastify · Knex · Postgres
Ferramenta
ChatGPT Plus, GPT-4o, janela do navegador
Método
copiar e colar; o modelo não lê o repositório
Preâmbulo fixo
159 tokens por mensagem

Nenhuma dessas ferramentas abre o repositório sozinha

Vale situar o que existe e quanto custa, porque daqui a um ano essa lista vai parecer pré-histórica e o preço é a única parte que não depende da minha opinião.

Ferramenta Preço em nov/2024 O que ela faz com o meu arquivo
ChatGPT Plus $20/mês nada — eu colo e copio de volta
GitHub Copilot $10/mês completa a linha; no chat, sugere e eu aplico
Cursor Pro $20/mês edita o arquivo aberto quando eu aceito
Aider código aberto aplica o patch e commita, um pedido por vez
Preços de tabela vigentes em novembro de 2024, plano individual. O Aider é código aberto: o que se paga é o modelo para o qual você aponta ele. Nenhuma das quatro decide sozinha o que mudar no repositório — a decisão e a aplicação continuam sendo minhas.

O Cursor e o Aider encurtam o copiar-e-colar, cada um do seu jeito, e valem o que custam. Mas o assunto deste post é o modo que ainda é o mais comum para tirar um projeto do zero: uma aba do navegador, um prompt e a área de transferência.

Pedir o contrato antes do código muda o que volta

O primeiro pedido que fiz foi o pedido natural, aquele que qualquer pessoa digita sem pensar.

Faz uma API REST de pedidos em Node com TypeScript.
Precisa criar, listar, buscar por id e cancelar.

Voltou um arquivo só, bonito e imediatamente inútil. O trecho que interessa é este:

// resposta ao pedido vago: rota, regra de negócio e SQL no mesmo lugar
app.post('/orders', async (req, res) => {
  const { customerId, items } = req.body;
  const total = items.reduce((s, i) => s + i.price * i.qty, 0);
  const { rows } = await pool.query(
    'INSERT INTO orders (customer_id, total, status) VALUES ($1,$2,$3) RETURNING *',
    [customerId, total, 'pending'],
  );
  res.status(201).json(rows[0]);
});

Isso roda. E tem quatro problemas, sendo que só um deles é de estilo. O domínio veio em inglês, enquanto o resto do meu sistema fala português — detalhe cosmético que vira caos quando os dois convivem. Não há validação nenhuma: items pode ser undefined e o reduce estoura com 500. A resposta devolve a linha crua do banco, com as colunas internas junto. E o quarto é o de verdade: o total é calculado a partir do preço que o cliente mandou no corpo da requisição. Quem chama a API decide quanto paga. O modelo não inventou isso por maldade; ele completou o padrão mais comum de “carrinho vindo do front”, e eu não disse de onde vem o preço.

Aí eu mudei a primeira mensagem. Em vez de pedir código, pedi o contrato.

Antes de qualquer código: escreva o OpenAPI 3.1 de /pedidos.
Só o contrato — caminhos, parâmetros, corpos, códigos de erro.
Nenhuma implementação. Vou revisar e devolver corrigido.

O que volta é um YAML que eu leio em dois minutos, e é nesses dois minutos que a decisão importante acontece.

paths:
  /pedidos:
    post:
      summary: Cria um pedido
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [clienteId, itens]
              properties:
                clienteId: { type: string, format: uuid }
                itens:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [produtoId, quantidade]
                    properties:
                      produtoId: { type: string, format: uuid }
                      quantidade: { type: integer, minimum: 1 }
      responses:
        '201': { description: Criado }
        '409': { description: Produto sem estoque }
        '422': { description: Corpo inválido }

Repare no que não está no corpo da requisição: preco. Essa linha não estava na primeira versão do YAML — eu apaguei, e devolvi dizendo que preço vem da tabela de produtos, sempre. Foi uma edição de dez segundos num arquivo de texto, e ela mata o bug de cobrança antes de existir uma linha de código. Revisar prosa é caro e revisar contrato é barato: o contrato tem lugar definido para cada decisão, e um lugar vazio incomoda.

Os códigos de erro no fim do YAML também são meus, não dele. A primeira versão respondia 400 para tudo; 409 para produto sem estoque e 422 para corpo inválido foram duas linhas que eu troquei antes de existir implementação. Discutir isso no contrato custa dez segundos. Discutir depois, com um handler pronto já colado no editor e um teste verde em volta dele, custa uma conversa inteira — e normalmente eu deixo passar, porque já está funcionando.

O segundo ganho é mais prosaico e talvez maior. O YAML vira o artefato que eu colo no topo de toda mensagem seguinte. Numa conversa que não tem acesso ao meu disco, esse arquivo é a única memória de longo prazo que existe.

O que ele acerta é tudo que tem forma

Sendo justo antes de ser chato: a parte estrutural o GPT-4o entrega bem, rápido, e quase sempre de primeira.

Pedi o esquema de validação a partir do contrato — literalmente “gere o Zod que valida o corpo do POST /pedidos do OpenAPI acima”.

// esquemas/pedido.ts
export const criarPedidoSchema = z.object({
  clienteId: z.string().uuid(),
  itens: z
    .array(
      z.object({
        produtoId: z.string().uuid(),
        quantidade: z.number().int().positive(),
      }),
    )
    .min(1),
});

export type CriarPedidoDTO = z.infer<typeof criarPedidoSchema>;

Está certo. A única coisa que mexi foi a mensagem de erro, que veio em inglês. Separar rota de serviço e serviço de repositório, escrever o DTO, derivar o tipo do esquema, montar o try/catch que traduz erro de validação em 422, escrever o arquivo de teste com os describe no lugar: essa camada inteira sai pronta e compila. É trabalho chato e repetitivo, do tipo que consome uma tarde inteira de digitação sem exigir nenhuma decisão. Sai em segundos.

Isso é real e não é pouco. Também não é a parte difícil. A parte difícil é tudo que depende de alguma coisa que está no meu disco e não está na conversa — e é exatamente aí que começa a segunda metade do post.

A biblioteca que ele escreve é a que ele viu, não a que está no meu package.json

O modelo escreve a API da versão que apareceu no treino dele. Não a versão que está instalada aqui. Ele não tem como saber qual é, porque nunca abriu o arquivo.

Pedi a conexão com o Postgres, com pool e timeout, e colei o que voltou.

// pedido: "conexão com Postgres, com pool e timeout de conexão"
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
  idleTimeout: 30000,      // nesta versão a opção chama idleTimeoutMillis
  connectionTimeout: 2000, // e aqui, connectionTimeoutMillis
});

Nenhuma das duas linhas quebra nada. Objeto de configuração aceita chave desconhecida sem reclamar: o pool sobe com os padrões, e os dois timeouts que eu pedi simplesmente não existem. Em desenvolvimento isso é invisível, porque o banco responde em 3 ms e nada nunca espera. Aparece no dia em que o banco fica lento em produção e a requisição fica pendurada até alguém reparar.

Esse é o caso caro. O caso barato — import que não existe mais, função renomeada, assinatura trocada — o TypeScript pega em dois segundos e você corrige sem pensar. O que dói é a variante silenciosa: código que roda, que parece o que você pediu, e que faz outra coisa.

A mitigação é boba e funciona bem: colar as versões junto com o pedido, toda vez que ele tocar em biblioteca.

pnpm list --depth 0

dependencies: fastify 4.28.1 knex 3.1.0 pg 8.13.1 zod 3.23.8 jsonwebtoken 9.0.2

o bloco que eu colo no prompt sempre que o pedido encosta numa dependência

Com isso colado, a taxa de acerto sobe visivelmente. E mesmo assim ele volta a errar quando a thread se estica, porque aquele bloco vai ficando para trás na conversa enquanto o pedido de agora está na frente. Não é esquecimento no sentido humano; é o peso relativo do que está perto do fim do prompt.

A migration não bate com a entidade porque foram duas conversas

O tipo da entidade saiu cedo, junto com o serviço, e está exatamente como eu queria.

// tipos/pedido.ts — gerado na mensagem 6
export type Pedido = {
  id: string;
  clienteId: string;
  total: number;
  status: 'aberto' | 'pago' | 'cancelado';
  criadoEm: Date;
};

Oito mensagens depois, com o serviço já rodando contra um mock, pedi a migration do Knex para criar a tabela.

// migrations/20241112_cria_pedidos.ts — gerado na mensagem 14
export async function up(knex: Knex) {
  await knex.schema.createTable('pedidos', (t) => {
    t.increments('id');
    t.integer('cliente_id').notNullable();
    t.float('total');
    t.string('status').defaultTo('pending');
    t.timestamps(true, true);
  });
}

São cinco divergências em seis linhas. O id virou inteiro sequencial, e o tipo diz string porque é uuid. O clienteId virou cliente_id sem que ninguém configurasse conversão de caso, e sem chave estrangeira. O total é float, que é o jeito clássico de perder centavo em arredondamento — dinheiro é inteiro em centavos, e isso eu tinha dito na mensagem 6. O status ganhou default 'pending', em inglês, fora da união de três valores em português e sem nenhuma restrição no banco. E timestamps() cria created_at e updated_at, enquanto o tipo espera criadoEm.

Nada disso quebra o build. O TypeScript não conversa com o Postgres: o tipo da entidade é uma promessa que nenhum compilador verifica. Você descobre na primeira consulta real, e descobre em pedaços — primeiro o id, uma hora depois o status, e o float só quando alguém somar dez pedidos e o total fechar um centavo torto.

A causa é a mesma de sempre. Entre a mensagem 6 e a 14 entraram dezenas de blocos de código, e o modelo não tem o arquivo tipos/pedido.ts aberto ao lado. Ele tem o histórico da conversa, disputando atenção com tudo o mais. A correção é colar o tipo junto com o pedido da migration, e a lição é que “você lembra do tipo que você mesmo escreveu?” é uma pergunta que não faz sentido fazer.

Melhor que corrigir é inverter a ordem: pedir os dois na mesma mensagem, um embaixo do outro, para que ele escreva a tabela olhando para a entidade. Foi o que passei a fazer, e o problema praticamente sumiu. Continua sendo trabalho meu decidir que essas duas coisas precisam nascer juntas — não existe nada na conversa que informe ao modelo que há um Postgres do outro lado esperando bater com aquele tipo.

O auth vem certo por fora e errado por dentro

Pedi um middleware de autenticação com JWT. Voltou isto, e é aqui que o método fica realmente perigoso.

// pedido: "middleware de autenticação com JWT"
export async function autenticar(req, reply) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return reply.code(401).send({ erro: 'sem token' });

  const payload = jwt.decode(token);
  if (!payload) return reply.code(401).send({ erro: 'token inválido' });

  req.usuario = payload;
}

Antes do problema de verdade, um aviso de menor gravidade: a primeira versão veio com assinatura de Express, (req, res, next), com res.status().json() e next() no fim. O projeto é Fastify, e isso está escrito na bancada lá em cima, mas eu não repeti no prompt daquela mensagem. O modelo completou com o formato mais comum na internet. Esse erro é barato: não roda, você descobre em trinta segundos. Acima está a versão já convertida para preHandler do Fastify — que é onde mora o erro caro.

Todo o comportamento observável está correto. Sem header, 401. Header malformado, 401. String aleatória, 401. Token válido, passa e o req.usuario chega preenchido no handler. Você testa no Postman, funciona nos quatro casos, e segue em frente.

jwt.decode não verifica assinatura. Ele faz base64 e devolve o conteúdo. Qualquer pessoa que monte um JSON com o id de outro usuário, codifique e mande, entra como aquele usuário. E exp também não é checado, então token vencido em janeiro continua valendo em novembro. A correção é uma palavra: jwt.verify(token, segredo, { algorithms: ['HS256'] }). A opção algorithms no fim não é enfeite — sem ela, o verificador aceita o algoritmo que o próprio token declarar, que é uma classe inteira de ataque de graça.

Uma palavra e uma opção. Em revisão de código, esse arquivo passa: ele tem o formato exato de um middleware de auth, com os nomes certos e os 401 nos lugares certos.

O teste que passa e não testa nada

Este é o problema central, e é o que me fez escrever o post.

Depois do serviço de pedidos ficar de pé, pedi a coisa mais natural do mundo: “escreve os testes desse serviço com Vitest”. Voltou um arquivo verde.

// testes/pedidos.servico.spec.ts — o que voltou
import { describe, it, expect, vi } from 'vitest';
import { criarPedido } from '../src/servicos/pedidos';

vi.mock('../src/servicos/pedidos', () => ({
  criarPedido: vi.fn().mockResolvedValue({ id: '1', total: 100 }),
}));

describe('criarPedido', () => {
  it('deve criar um pedido', async () => {
    const pedido = await criarPedido({ clienteId: 'c1', itens: [] });
    expect(criarPedido).toHaveBeenCalled();
    expect(pedido.total).toBe(100);
  });
});

Leia a terceira linha depois dos imports. O teste mocka o módulo que está sob teste. O criarPedido que roda dentro do it não é a minha função: é um vi.fn() que devolve total: 100 porque a linha logo acima mandou devolver. Eu poderia apagar src/servicos/pedidos.ts inteiro e este arquivo continuaria verde. A asserção toHaveBeenCalled afirma que a função que o próprio teste acabou de chamar foi chamada. E o itens: [] na chamada é justamente o caso que o contrato manda responder 422 — o teste não repara, porque nada real executou.

pnpm vitest run

Test Files 1 passed (1) Tests 1 passed (1)

verde, sem tocar em uma linha do meu código

A segunda variante é mais sutil e apareceu bem mais vezes. Aqui o mock está no lugar certo — o repositório, que é a dependência — e o problema mudou de lugar.

// o mock está certo; a asserção é que não olha para nada
const repo = { salvar: vi.fn().mockResolvedValue({ id: 'p1' }) };

it('calcula o total do pedido', async () => {
  await criarPedido(repo, {
    clienteId: 'c1',
    itens: [{ produtoId: 'x', quantidade: 3 }],
  });
  expect(repo.salvar).toHaveBeenCalled();
});

Este roda o meu código de verdade, o que já é um degrau acima. Só que o nome do teste promete o cálculo do total e a asserção não olha para o total. Se criarPedido multiplicar pela quantidade, ignorar a quantidade, somar em vez de multiplicar ou devolver NaN, o teste fica verde nos quatro casos. toHaveBeenCalled sem argumentos é a asserção mais fraca da suíte: ela afirma que a execução passou por ali, o que um console.log também afirmaria.

O conserto é uma linha, e a diferença entre as duas linhas é o post inteiro:

expect(repo.salvar).toHaveBeenCalledWith(
  expect.objectContaining({ total: 2970 }),  // 3 × R$ 9,90 em centavos
);

Por que ele faz isso? Não é preguiça, é o alvo. Suíte verde é o único sinal de “terminei” que existe naquela conversa, e há dois caminhos para chegar lá: fazer o código funcionar, ou fazer o teste não olhar. O segundo é mais curto e tem exatamente a mesma aparência. Somado a isso, o modelo viu uma quantidade enorme de teste ruim no treino — toHaveBeenCalled é provavelmente a asserção mais escrita da história do JavaScript.

E não adianta olhar para a cobertura, que é o reflexo natural nessa hora. O primeiro arquivo, o que mocka o módulo sob teste, marca zero linha coberta do serviço e denuncia a si mesmo. A segunda variante faz o contrário: roda o meu código inteiro, cobre cada linha dele, e só não confere o resultado. Cobertura alta com uma asserção que aceita qualquer número. O relatório mede se a linha executou, nunca se alguém olhou para o que ela produziu.

O ritual que passei a fazer, sempre, logo depois de qualquer teste voltar: pedir para ele quebrar a função de propósito e dizer qual teste falha. Se a resposta for “nenhum”, o arquivo é decoração. Melhor ainda é fazer isso à mão — trocar um * por + no serviço e rodar. Leva quinze segundos e é o único jeito de saber se você tem um teste ou um enfeite verde.

Cada resposta traz de volta o que eu já tinha corrigido

O custo estrutural do copiar-e-colar não é o tempo do Ctrl+C. É que o modelo não vê o estado atual do repositório, então toda resposta é gerada a partir de um mundo que parou na última coisa que eu colei — e nele as minhas correções manuais nunca aconteceram.

O caso típico: eu conserto o jwt.decode à mão no editor. Três mensagens depois peço uma mudança na resposta de erro do middleware. Ele devolve o arquivo inteiro, educadamente reescrito, com o jwt.decode de volta no lugar. Não é regressão do modelo; é que a minha correção nunca passou por ele.

Contra isso, montei um preâmbulo fixo que vai no topo de toda mensagem que peça código.

ESTADO (não altere nada fora do que eu pedir)
- Node 20, TypeScript 5.6, ESM, Fastify, Knex, Zod, Vitest
- Domínio em português: pedidos, itens, clientes. Nunca order/item.
- id é uuid gerado no banco. Nunca increments.
- Dinheiro é inteiro em centavos. Nunca float.
- Erro sai em ProblemDetails (RFC 7807).
- Contrato vigente: o OpenAPI da mensagem 1 (/pedidos e /pedidos/{id}).
- Já corrigido, não regrida: jwt.verify com algorithms HS256.

PEDIDO
<uma tarefa, uma só>

ARQUIVO ATUAL
<cola do arquivo que vai mudar, inteiro>

Custa 159 tokens por mensagem — contei com o tokenizer do GPT-4o sobre o bloco acima — e é a coisa mais barata do processo. A US$ 2,50 por milhão de tokens de entrada, dá US$ 0,0004 por mensagem: trinta mensagens de preâmbulo custam pouco mais de um centavo. A linha que mais paga é a última do bloco de estado: “já corrigido, não regrida” é o que fez os mesmos três bugs pararem de voltar a cada reescrita. E não resolve tudo: passado certo ponto da mesma thread, ele volta a inventar. Quando isso começa, abro thread nova e colo contrato mais estado; sai mais barato que brigar com o histórico.

Vale registrar o que esse ritual é, olhando de fora: eu digitando à mão, toda mensagem, uma versão pior e manual daquilo que a ferramenta deveria ler do disco sozinha. É um índice de repositório mantido por um humano com a área de transferência.

ChatGPT, Claude e Grok na mesma tarefa: o que dá para afirmar

Fiz partes do mesmo trabalho nos três. Antes das impressões, os preços — que são fato, ao contrário do resto desta seção.

Modelo (API) Entrada por 1M Saída por 1M
GPT-4o $2,50 $10
GPT-4o mini $0,15 $0,60
Claude 3.5 Sonnet $3 $15
xAI grok-beta $5 $15
Preços de tabela vigentes em novembro de 2024, por 1 milhão de tokens. A API da xAI entrou em beta público em 4 de novembro de 2024, duas semanas atrás, com $25 por mês de crédito — foi isso, e não a curiosidade técnica, que colocou o grok-beta na comparação de muita gente, inclusive na minha. Este trabalho foi feito no ChatGPT Plus a $20/mês, não pela API; a tabela está aqui porque é o custo de automatizar o mesmo fluxo.

GPT-4o. Rápido e econômico na resposta. Tende a devolver o trecho e escrever “o resto continua igual”, o que é ótimo quando eu sei onde encaixar e péssimo quando o arquivo é novo. Foi o que mais consistentemente transformou a minha prosa em OpenAPI aproveitável de primeira, nesta tarefa.

Claude 3.5 Sonnet. Devolve arquivo mais completo e escreve mais. Aguentou melhor as colagens longas — arquivo inteiro mais contrato mais erro — sem perder o que estava no começo. Também é o que mais me devolveu o arquivo todo em vez do trecho, que é exatamente o comportamento contra o qual a seção anterior avisa. Manteve o domínio em português depois de eu pedir uma vez.

grok-beta. Deu para trabalhar. Não observei nada que me fizesse trocar de ferramenta para esta tarefa, e o motivo de ele estar aqui é o crédito do beta, não uma hipótese minha sobre o modelo.

Duas notas de rodapé que só fazem sentido hoje. O GPT-4o mini, a $0,15 e $0,60 por 1M, é barato o suficiente para tratar o boilerplate — DTO, mapeamento, esqueleto de teste — como coisa descartável, no dia em que eu trocar o navegador por um script. E o o1-preview está disponível, mas não é para isto: ele pensa antes de responder, e o que esta tarefa pede é o contrário, despejar arquivo atrás de arquivo enquanto eu colo. Reservei ele para a pergunta difícil isolada, não para o laço.

O que dá para afirmar com mais segurança é chato: a distância entre os três é bem menor que a distância entre “escrevi o contrato antes” e “não escrevi”. Trocar de modelo mudou o estilo do que volta. Escrever o contrato mudou o que volta.

O que separa as duas metades

Três dos quatro lugares onde o chat me enrolou têm a mesma forma: a versão da biblioteca, a migration e o teste verde. Todos dependem de uma informação que está no meu disco e não está no meu prompt: o package.json, o arquivo de tipo escrito oito mensagens antes, o comportamento real da função sob teste. Onde o modelo pode gerar a partir do que eu escrevi, ele é bom e é rápido. Onde ele precisaria ler alguma coisa que eu não colei, ele completa com o mais provável, e o mais provável é sempre plausível, o que é bem pior que absurdo. Código absurdo eu pego em dez segundos.

O auth é o quarto lugar e não entra nesse padrão. Ali não faltava informação minha: não existe arquivo no meu disco que responda se jwt.decode verifica assinatura. Faltava o modelo escolher o default seguro em vez do mais frequente. É por isso que ele é o mais perigoso dos quatro — colar mais contexto não resolve, e é exatamente o conhecimento que você terceirizou ao pedir o middleware pronto.

Então o trabalho, hoje, é decidir o que entra no texto. Pedir o contrato antes do código é isso e nada mais: mover para dentro do prompt a decisão que, se ficar de fora, o modelo toma sozinho e em silêncio. Colar as versões é isso. O bloco de estado no topo da mensagem é isso.

Uma parte desse ritual vai ficar obsoleta assim que a ferramenta abrir o repositório sozinha, e ainda bem. A outra parte não vai: um teste que mocka a coisa sob teste continua verde independentemente de quem abriu o arquivo, e nenhum acesso a disco conserta uma asserção que não olha para o resultado.

Enquanto isso, o papel é claro. Nesta conversa eu faço de compilador e de linter, e tudo bem. O que incomoda de verdade é fazer de memória.