Una API entera por el chat: lo que acierta GPT-4o y dónde te engaña
En noviembre de 2024 ningún agente edita mis archivos: la API sale del chat en bloques que pego a mano. El GPT-4o acierta la estructura y me engaña en cuatro lugares — el peor es la prueba que pasa sin probar nada.
¿Con prisa? Pide el TL;DR a Claude — lee la página y la resume.
es Traducción automática por mistral-small3.2:24b, revisada por el autor. Leer el original en portugués
El chat escribe toda tu API. Solo nunca va a saber que existe.
Esto no es una metáfora. En noviembre de 2024 no existe la cosa que abre mi repositorio, lee los archivos, decide qué cambiar, aplica, ejecuta el test y vuelve con el resultado. Existe Cursor, que edita el archivo que está abierto cuando acepto la sugerencia. Existe Aider, que aplica el parche y commitea lo que mandé aplicar. Existe Copilot completando la línea. Pero para construir algo desde cero, el flujo que yo —y casi todo el mundo que conozco— de hecho uso es el más burro posible: describo, el modelo devuelve un bloque, copio en el editor, ejecuto y vuelvo con el error pegado de nuevo.
Copiar y pegar no es un detalle del método. Es el método.
El bucle siempre es igual. Yo describo, él devuelve, yo pego, TypeScript se queja, yo copio el error de vuelta, él corrige esa línea e introduce otra cosa dos bloques más arriba. Quien cierra el ciclo soy yo, una ronda por vez, con el portapapeles. Y el detalle que explica la mitad de los problemas de este post: el modelo nunca ve el resultado de ejecutar lo que escribió —él ve mi transcripción del resultado, filtrada por lo que yo consideré relevante pegar.
Hice esto de principio a fin en una API REST de pedidos. Veredicto corto: GPT-4o acierta lo que tiene forma predecible y me enrolla en cuatro lugares. En los cuatro, el error solo aparece después de pegar.
Lo que se construyó · noviembre de 2024
- Tarea
- API REST de pedidos, 4 endpoints
- Stack
- Node 20 · TypeScript 5.6 · Fastify · Knex · Postgres
- Herramienta
- ChatGPT Plus, GPT-4o, ventana del navegador
- Método
- copiar y pegar; el modelo no lee el repositorio
- Preámbulo fijo
- 159 tokens por mensaje
Ninguna de estas herramientas abre el repositorio sola
Vale situar lo que existe y cuánto cuesta, porque dentro de un año esta lista va a parecer prehistórica y el precio es la única parte que no depende de mi opinión.
| Herramienta | Precio en nov/2024 | Lo que hace con mi archivo |
|---|---|---|
| ChatGPT Plus | $20/mes | nada — yo pego y copio de vuelta |
| GitHub Copilot | $10/mes | completa la línea; en el chat, sugiere y yo aplico |
| Cursor Pro | $20/mes | edita el archivo abierto cuando yo acepto |
| Aider | código abierto | aplica el parche y commitea, un pedido por vez |
Cursor y Aider acortan el copiar-y-pegar, cada uno a su manera, y valen lo que cuestan. Pero el tema de este post es el modo que aún es el más común para sacar un proyecto desde cero: una pestaña del navegador, un prompt y el portapapeles.
Pedir el contrato antes del código cambia lo que vuelve
La primera solicitud que hice fue la solicitud natural, aquella que cualquier persona escribe sin pensar.
Faz uma API REST de pedidos em Node com TypeScript.
Precisa criar, listar, buscar por id e cancelar.
Volvió un archivo solo, bonito e inmediatamente inútil. El fragmento que interesa es este:
// respuesta a la solicitud vaga: ruta, regla de negocio y SQL en el mismo 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]);
});
Esto se ejecuta. Y tiene cuatro problemas, siendo que solo uno de ellos es de estilo. El dominio vino en inglés, mientras el resto de mi sistema habla portugués — detalle cosmético que se convierte en caos cuando los dos conviven. No hay validación alguna: items puede ser undefined y el reduce explota con 500. La respuesta devuelve la línea cruda de la base de datos, con las columnas internas juntas. Y el cuarto es el verdadero: el total se calcula a partir del precio que el cliente envió en el cuerpo de la solicitud. Quien llama a la API decide cuánto paga. El modelo no inventó esto por maldad; completó el patrón más común de “carrito venido del front”, y yo no dije de dónde viene el precio.
Entonces cambié el primer mensaje. En lugar de pedir código, pedí el 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.
Lo que vuelve es un YAML que leo en dos minutos, y son esos dos minutos donde ocurre la decisión importante.
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 }
Fíjate en lo que no está en el cuerpo de la solicitud: preco. Esa línea no estaba en la primera versión del YAML — yo la borré, y devolví diciendo que el precio viene de la tabla de productos, siempre. Fue una edición de diez segundos en un archivo de texto, y ella mata el bug de cobro antes de existir una línea de código. Revisar prosa es caro y revisar contrato es barato: el contrato tiene lugar definido para cada decisión, y un lugar vacío incomoda.
Los códigos de error al final del YAML también son míos, no suyos. La primera versión respondía 400 para todo; 409 para producto sin stock y 422 para cuerpo inválido fueron dos líneas que yo cambié antes de existir implementación. Discutir esto en el contrato cuesta diez segundos. Discutir después, con un handler listo ya pegado en el editor y un test verde a su alrededor, cuesta una conversación entera — y normalmente lo dejo pasar, porque ya está funcionando.
La segunda ganancia es más prosaica y quizás mayor. El YAML se convierte en el artefacto que pego al principio de cada mensaje siguiente. En una conversación que no tiene acceso a mi disco, ese archivo es la única memoria de largo plazo que existe.
Lo que él acierta es todo lo que tiene forma
Siendo justo antes de ser molesto: la parte estructural GPT-4o entrega bien, rápido y casi siempre de primera.
Pedí el esquema de validación a partir del contrato — literalmente “genera el Zod que valida el cuerpo del POST /pedidos del OpenAPI arriba”.
// 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á correcto. La única cosa que cambié fue el mensaje de error, que vino en inglés. Separar ruta de servicio y servicio de repositorio, escribir el DTO, derivar el tipo del esquema, montar el try/catch que traduce error de validación en 422, escribir el archivo de test con los describe en su lugar: esa capa entera sale lista y compila. Es trabajo aburrido y repetitivo, del tipo que consume una tarde entera de escritura sin exigir ninguna decisión. Sale en segundos.
Esto es real y no es poco. Tampoco es la parte difícil. La parte difícil es todo lo que depende de algo que está en mi disco y no está en la conversación — y es exactamente ahí donde comienza la segunda mitad del post.
La biblioteca que él escribe es la que vio, no la que está en mi package.json
El modelo escribe la API de la versión que apareció en su entrenamiento. No la versión que está instalada aquí. Él no tiene forma de saber cuál es, porque nunca abrió el archivo.
Pedí la conexión con Postgres, con pool y timeout, y pegué lo que volvió.
// pedido: "conexión con Postgres, con pool y timeout de conexión"
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
});
Ninguna de las dos líneas rompe nada. Objeto de configuración acepta clave desconocida sin quejarse: el pool se levanta con los estándares, y los dos timeouts que pedí simplemente no existen. En desarrollo esto es invisible, porque la base de datos responde en 3 ms y nunca espera. Aparece el día en que la base de datos se pone lenta en producción y la solicitud queda colgada hasta que alguien note.
Este es el caso caro. El caso barato — import que ya no existe, función renombrada, firma cambiada — TypeScript lo atrapa en dos segundos y tú lo corriges sin pensar. Lo que duele es la variante silenciosa: código que se ejecuta, que parece lo que pediste, y que hace otra cosa.
La mitigación es tonta y funciona bien: pegar las versiones junto con la solicitud, cada vez que él toque en 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
Con esto pegado, la tasa de acierto sube visiblemente. Y aún así él vuelve a equivocarse cuando el hilo se estira, porque ese bloque va quedando atrás en la conversación mientras la solicitud de ahora está al frente. No es olvido en el sentido humano; es el peso relativo de lo que está cerca del final del prompt.
La migración no coincide con la entidad porque fueron dos conversaciones
El tipo de la entidad salió temprano, junto con el servicio, y está exactamente como yo quería.
// tipos/pedido.ts — generado en el mensaje 6
export type Pedido = {
id: string;
clienteId: string;
total: number;
status: 'aberto' | 'pago' | 'cancelado';
criadoEm: Date;
};
Ocho mensajes después, con el servicio ya ejecutándose contra un mock, pedí la migración de Knex para crear la tabla.
// migrations/20241112_cria_pedidos.ts — generado en el mensaje 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);
});
}
Hay cinco divergencias en seis líneas. El id se convirtió en entero secuencial, y el tipo dice string porque es uuid. El clienteId se convirtió en cliente_id sin que nadie configurara conversión de caso, y sin clave externa. El total es float, que es la forma clásica de perder centavo en redondeo — dinero es entero en centavos, y eso yo lo había dicho en el mensaje 6. El status ganó default 'pending', en inglés, fuera de la unión de tres valores en portugués y sin ninguna restricción en la base de datos. Y timestamps() crea created_at y updated_at, mientras que el tipo espera criadoEm.
Nada de esto rompe el build. TypeScript no habla con Postgres: el tipo de la entidad es una promesa que ningún compilador verifica. Tú lo descubres en la primera consulta real, y lo descubres en pedazos — primero el id, una hora después el status, y el float solo cuando alguien sume diez pedidos y el total cierre un centavo torcido.
La causa es la misma de siempre. Entre el mensaje 6 y el 14 entraron docenas de bloques de código, y el modelo no tiene el archivo tipos/pedido.ts abierto al lado. Él tiene el historial de la conversación, compitiendo por atención con todo lo demás. La corrección es pegar el tipo junto con la solicitud de migración, y la lección es que “¿recuerdas el tipo que tú mismo escribiste?” es una pregunta que no tiene sentido hacer.
Mejor que corregir es invertir el orden: pedir los dos en el mismo mensaje, uno debajo del otro, para que él escriba la tabla mirando a la entidad. Fue lo que comencé a hacer, y el problema prácticamente desapareció. Sigue siendo trabajo mío decidir que esas dos cosas necesitan nacer juntas — no existe nada en la conversación que informe al modelo que hay un Postgres del otro lado esperando coincidir con ese tipo.
El auth viene correcto por fuera e incorrecto por dentro
Pedí un middleware de autenticación con JWT. Volvió esto, y es aquí donde el método se vuelve realmente peligroso.
// pedido: "middleware de autenticación con 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 del problema real, una advertencia de menor gravedad: la primera versión vino con firma de Express, (req, res, next), con res.status().json() y next() al final. El proyecto es Fastify, y esto está escrito en el tablero arriba, pero yo no lo repetí en el prompt de ese mensaje. El modelo completó con el formato más común en internet. Este error es barato: no se ejecuta, tú lo descubres en treinta segundos. Arriba está la versión ya convertida para preHandler de Fastify — que es donde vive el error caro.
Todo el comportamiento observable está correcto. Sin header, 401. Header malformado, 401. Cadena aleatoria, 401. Token válido, pasa y el req.usuario llega lleno en el handler. Tú lo pruebas en Postman, funciona en los cuatro casos, y sigues adelante.
jwt.decode no verifica firma. Él hace base64 y devuelve el contenido. Cualquier persona que monte un JSON con el id de otro usuario, codifique y envíe, entra como ese usuario. Y exp tampoco es chequeado, entonces token vencido en enero sigue valiendo en noviembre. La corrección es una palabra: jwt.verify(token, secreto, { algorithms: ['HS256'] }). La opción algorithms al final no es adorno — sin ella, el verificador acepta el algoritmo que el propio token declare, que es una clase entera de ataque gratis.
Una palabra y una opción. En revisión de código, este archivo pasa: tiene el formato exacto de un middleware de auth, con los nombres correctos y los 401 en los lugares correctos.
El test que pasa y no prueba nada
Este es el problema central, y es lo que me hizo escribir el post.
Después de que el servicio de pedidos quedara en pie, pedí la cosa más natural del mundo: “escribe los tests de este servicio con Vitest”. Volvió un archivo verde.
// pruebas/pedidos.servico.spec.ts — lo que volvió
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);
});
});
Lee la tercera línea después de las importaciones. El test mockea el módulo que está bajo prueba. El criarPedido que se ejecuta dentro del it no es mi función: es un vi.fn() que devuelve total: 100 porque la línea justo arriba mandó devolver. Yo podría borrar src/servicos/pedidos.ts entero y este archivo seguiría verde. La aserción toHaveBeenCalled afirma que la función que el propio test acaba de llamar fue llamada. Y el itens: [] en la llamada es justo el caso que el contrato manda responder 422 — el test no se da cuenta, porque nada real ejecutó.
pnpm vitest run Test Files 1 passed (1) Tests 1 passed (1)
La segunda variante es más sutil y apareció mucho más veces. Aquí el mock está en el lugar correcto — el repositorio, que es la dependencia — y el problema cambió de lugar.
// el mock está correcto; la aserción es que no mira 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 ejecuta mi código real, lo cual ya es un escalón arriba. Solo que el nombre del test promete el cálculo del total y la aserción no mira al total. Si criarPedido multiplica por la cantidad, ignora la cantidad, suma en vez de multiplicar o devuelve NaN, el test se queda verde en los cuatro casos. toHaveBeenCalled sin argumentos es la aserción más débil del suite: afirma que la ejecución pasó por allí, lo cual un console.log también afirmaría.
La corrección es una línea, y la diferencia entre las dos líneas es todo el post:
expect(repo.salvar).toHaveBeenCalledWith(
expect.objectContaining({ total: 2970 }), // 3 × R$ 9,90 em centavos
);
¿Por qué él hace esto? No es pereza, es el objetivo. Suite verde es la única señal de “terminé” que existe en esa conversación, y hay dos caminos para llegar allí: hacer que el código funcione o hacer que el test no mire. El segundo es más corto y tiene exactamente la misma apariencia. Sumado a esto, el modelo vio una cantidad enorme de tests malos en su entrenamiento — toHaveBeenCalled probablemente es la aserción más escrita en la historia de JavaScript.
Y no sirve mirar la cobertura, que es el reflejo natural en este momento. El primer archivo, el que mockea el módulo bajo prueba, marca cero línea cubierta del servicio y se denuncia a sí mismo. La segunda variante hace lo contrario: ejecuta mi código entero, cubre cada línea de él, y solo no verifica el resultado. Cobertura alta con una aserción que acepta cualquier número. El informe mide si la línea ejecutó, nunca si alguien miró lo que produjo.
El ritual que comencé a hacer, siempre, justo después de cualquier test volver: pedirle que rompa la función a propósito y diga qué test falla. Si la respuesta es “ninguno”, el archivo es decoración. Mejor aún es hacerlo a mano — cambiar un * por + en el servicio y ejecutar. Lleva quince segundos y es la única manera de saber si tienes un test o una decoración verde.
Cada respuesta trae de vuelta lo que yo ya había corregido
El costo estructural del copiar-y-pegar no es el tiempo del Ctrl+C. Es que el modelo no ve el estado actual del repositorio, entonces cada respuesta se genera a partir de un mundo que paró en la última cosa que pegué — y en él mis correcciones manuales nunca ocurrieron.
El caso típico: yo corrijo el jwt.decode a mano en el editor. Tres mensajes después pido un cambio en la respuesta de error del middleware. Él devuelve el archivo entero, educadamente reescrito, con el jwt.decode de vuelta en su lugar. No es regresión del modelo; es que mi corrección nunca pasó por él.
Contra esto, monté un preámbulo fijo que va al principio de cada mensaje que pida 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>
Cuesta 159 tokens por mensaje — conté con el tokenizer de GPT-4o sobre el bloque arriba — y es la cosa más barata del proceso. A US$ 2,50 por millón de tokens de entrada, da US$ 0,0004 por mensaje: treinta mensajes de preámbulo cuestan poco más de un centavo. La línea que más paga es la última del bloque de estado: “ya corregido, no regrida” es lo que hizo que los mismos tres bugs dejaran de volver en cada reescritura. Y no resuelve todo: pasado cierto punto del mismo hilo, él vuelve a inventar. Cuando esto comienza, abro un nuevo hilo y pego contrato más estado; sale más barato que pelear con el historial.
Vale registrar qué es este ritual, mirando desde fuera: yo escribiendo a mano, cada mensaje, una versión peor y manual de aquello que la herramienta debería leer del disco sola. Es un índice de repositorio mantenido por un humano con el portapapeles.
ChatGPT, Claude y Grok en la misma tarea: lo que se puede afirmar
Hice partes del mismo trabajo en los tres. Antes de las impresiones, los precios — que son hecho, a diferencia del resto de esta sección.
| Modelo (API) | Entrada por 1M | Salida 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 |
GPT-4o. Rápido y económico en la respuesta. Tiende a devolver el fragmento y escribir “el resto sigue igual”, lo cual es genial cuando sé dónde encajar y terrible cuando el archivo es nuevo. Fue el que más consistentemente transformó mi prosa en OpenAPI aprovechable de primera, en esta tarea.
Claude 3.5 Sonnet. Devuelve archivo más completo y escribe más. Aguantó mejor los pegados largos — archivo entero más contrato más error — sin perder lo que estaba al principio. También es el que más me devolvió el archivo entero en vez del fragmento, que es exactamente el comportamiento contra el cual la sección anterior advierte. Mantuvo el dominio en portugués después de yo pedirlo una vez.
grok-beta. Se pudo trabajar. No observé nada que me hiciera cambiar de herramienta para esta tarea, y la razón de que esté aquí es el crédito del beta, no una hipótesis mía sobre el modelo.
Dos notas al pie que solo tienen sentido hoy. GPT-4o mini, a $0,15 y $0,60 por 1M, es barato suficiente para tratar el boilerplate — DTO, mapeo, esqueleto de test — como cosa descartable, en el día en que cambie el navegador por un script. Y o1-preview está disponible, pero no es para esto: él piensa antes de responder, y lo que esta tarea pide es lo contrario, despejar archivo tras archivo mientras yo pego. Lo reservé para la pregunta difícil aislada, no para el bucle.
Lo que se puede afirmar con más seguridad es aburrido: la distancia entre los tres es mucho menor que la distancia entre “escribí el contrato antes” y “no escribí”. Cambiar de modelo cambió el estilo de lo que vuelve. Escribir el contrato cambió lo que vuelve.
Lo que separa las dos mitades
Tres de los cuatro lugares donde el chat me enrolló tienen la misma forma: la versión de la biblioteca, la migración y el test verde. Todos dependen de una información que está en mi disco y no está en mi prompt: el package.json, el archivo de tipo escrito ocho mensajes antes, el comportamiento real de la función bajo prueba. Donde el modelo puede generar a partir de lo que yo escribí, él es bueno y es rápido. Donde él necesitaría leer algo que yo no pegué, él completa con lo más probable, y lo más probable siempre es plausible, lo cual es mucho peor que absurdo. Código absurdo yo lo atrapo en diez segundos.
El auth es el cuarto lugar y no entra en este patrón. Allí no faltaba información mía: no existe archivo en mi disco que responda si jwt.decode verifica firma. Le faltó al modelo elegir el default seguro en vez del más frecuente. Es por eso que él es el más peligroso de los cuatro — pegar más contexto no resuelve, y es exactamente el conocimiento que tú tercerizaste al pedir el middleware listo.
Entonces el trabajo, hoy, es decidir qué entra en el texto. Pedir el contrato antes del código es esto y nada más: mover dentro del prompt la decisión que, si queda afuera, el modelo toma solo y en silencio. Pegar las versiones es esto. El bloque de estado al principio del mensaje es esto.
Una parte de este ritual va a quedar obsoleta tan pronto como la herramienta abra el repositorio sola, y aún mejor. La otra parte no: un test que mockea la cosa bajo prueba sigue verde independientemente de quién abrió el archivo, y ningún acceso a disco corrige una aserción que no mira al resultado.
Mientras tanto, el papel es claro. En esta conversación yo hago de compilador y de linter, y está bien. Lo que molesta de verdad es hacer de memoria.