¿Por qué el código generado por IA puede fallar por un método que nunca existió?.. y cómo detectarlo con un registro de APIs válidas.
El método que nunca existió
Hace poco revisé código que había salido de un asistente de IA. Pasaba los tests, pasaba el linter, pasaba el type checker. Y sin embargo, en una ruta que los tests no cubrían, había una llamada a requests.get_json(). Ese método no existe en requests. Nunca ha existido. Si esa ruta se hubiera ejecutado en producción, habría fallado con un AttributeError que ningún test habría visto antes.
No es un caso aislado. Los modelos generan código que parece correcto: la sintaxis es válida, el estilo es razonable, los nombres suenan a algo. El problema es que a veces los nombres suenan a algo que no existe.
Un comentarista del artículo que publiqué antes sobre esto, Greco, lo resumió mejor que yo: en stacks tipados (TypeScript en strict, o Python con mypy/pyright) el chequeo de tipos atrapa parte de estos casos. Pero en código dinámico, o en rutas que los tests no ejercitan, un método inventado puede llegar tranquilamente a producción y fallar recién cuando alguien pasa por ahí.
Este artículo es sobre el hueco que queda entre lo que el linter ve, lo que el type checker ve, y lo que de verdad existe en la librería que estás usando.
El problema, en tres capas
Cuando hablo de “código generado por IA que falla por algo que no existe”, hay tres capas distintas, y conviene no mezclarlas:
Capa 1 — Alucinación de API. La IA inventa un método que no existe en la librería. El caso que detecté en mi mini-proyecto realista:
text
app/services/user_service.py:4 — posible alucinación de API: requests.get_json
requests no tiene get_json. Tiene response.json(), sobre la respuesta, no sobre el módulo. La IA mezcló requests con Flask (request.get_json()), que es un método real pero de otra librería. El código parece correcto hasta que se ejecuta.
Capa 2 — Paquete inexistente o typosquatting. La IA inventa un nombre de paquete entero (fast_json_parser) o escribe mal uno real (reqeusts por requests, numpi por numpy, pandaas por pandas). Si alguien registra ese nombre inventado en PyPI o npm con código malicioso, tienes un ataque de cadena de suministro. Es lo que se ha dado en llamar slopsquatting, y es el primo peligroso del que hablaba Greco.
Capa 3 — Reglas internas del equipo. Llamadas prohibidas, imports prohibidos, manejo de errores obligatorio. Esto sí lo cubren Semgrep y compañía, pero lo incluyo porque una herramienta de detección de código generado por IA no debería ignorarlo: si tu equipo tiene reglas, la IA también debe cumplirlas.
La frase clave: el linter ve lo que conoce. El type checker ve lo que está en la firma. Ninguno de los dos sabe si el método existe de verdad en la librería que estás usando.
Qué no cubre (y por qué eso importa)
Ser honesto sobre los límites es lo que hace útil una herramienta. Esto es lo que no cubre por ahora:
· TypeScript strict / mypy / pyright: atrapan parte de los casos, pero no si la firma existe y la implementación no. Y no cubren código dinámico.
· Linter (ESLint, Ruff): no saben qué APIs existen en la librería. Solo miran tu código. De hecho, Ruff sí marca imports muertos (F401), pero no imports que apuntan a un paquete inexistente.
· SAST (Semgrep, CodeQL): están diseñados para vulnerabilidades conocidas, no para “esto no existe”.
· Tests: solo cubren lo que ejercitan. La ruta que falla es, casi por definición, la que no se ejercita.
No es que estas herramientas fallen. Es que no están diseñadas para ver esto.
Cómo funciona el registro de APIs válidas
El corazón de la herramienta es un mapa: librería → conjunto de funciones válidas. De momento cubre requests, json y os, que son las que más aparecen en el código que he revisado.
go
var apiRegistry = map[string]map[string]bool{
"requests": {
"get": true, "post": true, "put": true, "delete": true,
"head": true, "options": true, "patch": true, "request": true,
"Session": true, "session": true,
},
"json": {
"loads": true, "dumps": true, "load": true, "dump": true,
"JSONDecoder": true, "JSONEncoder": true,
},
"os": {
"getcwd": true, "chdir": true, "listdir": true, "mkdir": true,
"makedirs": true, "remove": true, "rmdir": true, "rename": true,
"path": true, "environ": true, "getenv": true, "system": true,
"walk": true, "stat": true,
// Submódulo os.path. En Fase 1 se modela como entradas planas
// con notación "path.<función>" porque la llamada se parte en dos.
"path.join": true, "path.dirname": true, "path.abspath": true,
"path.exists": true, "path.isfile": true, "path.isdir": true,
"path.basename": true, "path.splitext": true, "path.normpath": true,
"path.realpath": true, "path.relpath": true, "path.getsize": true,
},
}
La comprobación es deliberadamente simple: si la llamada tiene la forma libreria.funcion y la librería está en el registro, se busca funcion en su conjunto. Si no está, es una alucinación.
go
func isAPIHallucination(callName string) bool {
parts := strings.SplitN(callName, ".", 2)
if len(parts) != 2 {
return false // no es "libreria.funcion", no aplica
}
lib, fn := parts[0], parts[1]
validFuncs, known := apiRegistry[lib]
if !known {
return false // librería desconocida: no podemos juzgar
}
return !validFuncs[fn]
}
Obsérvese la línea if !known { return false }. Es una decisión de diseño importante: si la librería no está en el registro, no se marca nada. La herramienta solo juzga lo que conoce. Eso evita falsos positivos masivos, pero también significa que la cobertura es tan buena como el registro. Ampliarlo es trabajo artesanal, y es el siguiente paso del roadmap.
Un ejemplo real, con el fixture del Nivel 1. La entrada:
python
import requests
import json
import os
def fetch_data():
# Alucinación: requests.get_json no existe (es response.json())
response = requests.get_json("https://api.example.com/data")
return response
def parse_json(text):
# Alucinación: json.loads_string no existe (es json.loads)
return json.loads_string(text)
def read_file(path):
# Alucinación: os.read_file no existe (es open)
return os.read_file(path)
def correct_calls():
# Estas sí son válidas, no deben marcarse
requests.post("https://api.example.com", json={})
json.dumps({"a": 1})
os.getcwd()
Y la salida de la CLI:
text
Hallazgos: 3
[error] hallucinations.py:7 — posible alucinación de API: requests.get_json (api-hallucination)
[error] hallucinations.py:12 — posible alucinación de API: json.loads_string (api-hallucination)
[error] hallucinations.py:16 — posible alucinación de API: os.read_file (api-hallucination)
Tres alucinaciones detectadas. Y, tan importante como eso, correct_calls() no genera ningún hallazgo: requests.post, json.dumps y os.getcwd están en el registro y no se marcan. Un detector que no distingue entre lo correcto y lo inventado no sirve; este sí distingue.
El registro es finito y artesanal a propósito. No pretende cubrir toda la stdlib ni todas las librerías: cubre las que más aparecen en código generado por IA. Ese es el compromiso, y es la parte menos glamurosa y la más importante del proyecto.
Qué generan los modelos cuando les pides código
Para probar la herramienta con código generado por IA de verdad, le pedí a dos modelos (Z.ai y ChatGPT) una serie de scripts Python con tareas cotidianas. La idea no era buscar alucinaciones a la fuerza, sino ver qué pasa cuando les pides cosas normales.
El resultado fue más matizado de lo que esperaba.
Z.ai: 5 prompts, 1 alucinación forzada.
Le pedí cinco scripts: un cliente HTTP que parsea JSON, un filtrador de líneas de archivo, un procesador de CSV con pandas, un cálculo estadístico con numpy, y —este es el interesante— un script que usara dos librerías que no existen (fast_json_parser y my_custom_lib_that_doesnt_exist).
Los cuatro primeros: cero alucinaciones visibles. Z.ai usó APIs reales de requests, pathlib, pandas y numpy. En algunos casos, con calidad de producción: validación tipada con pydantic, reintentos con urllib3.Retry, subcomandos con argparse, tests unitarios incorporados.
El quinto: aquí pasó algo curioso. La primera vez, Z.ai se resistió. Dijo que no conocía esas librerías y propuso sustitutos reales (orjson y jsonschema). Al insistirle, obedeció: inventó APIs plausibles (fast_json_parser.load_from_file, SchemaValidator), las documentó como inventadas, y añadió # type: ignore[import-not-found] para que el type checker no interfiriera con la prueba.
ChatGPT: 3 prompts, 1 alucinación forzada.
Le pedí un script complejo que leyera un config.json, verificara URLs con requests, y guardara un reporte. Cero alucinaciones: usó requests.get, respuesta.raise_for_status(), respuesta.json(), json.load, json.dump, os.path.exists, todo correcto.
Le pedí el mismo prompt 1 de Z.ai (descargar y parsear JSON con requests). Cero alucinaciones: usó respuesta.json(), que es la API correcta. Y aquí está el contraste que me parece más valioso del artículo: en mi mini-proyecto del Nivel 4, el código alucinaba requests.get_json(). ChatGPT, con el mismo tipo de tarea, usó respuesta.json(). Mismo concepto, dos resultados. La diferencia no es que un modelo sea mejor que otro: es que la alucinación ocurre a veces, y cuando ocurre, el código parece correcto.
Y le pedí el mismo prompt 5 de Z.ai (paquete inventado). ChatGPT también se resistió la primera vez, propuso ijson y jsonschema, y al insistirle obedeció e inventó APIs distintas a las de Z.ai: StreamParser y validate_schema.
| Modelo | Prompt | Archivos | Hallazgos CLI | Alucinaciones visibles | APIs inventadas |
|---|---|---|---|---|---|
| Z.ai | 5 prompts | 5 | 2 (package-exists) |
1 de 5 (forzada) | load_from_file, SchemaValidator |
| ChatGPT | Config URLs | 1 | 0 | 0 | — |
| ChatGPT | Prompt 1 de Z.ai | 1 | 0 | 0 | — |
| ChatGPT | Paquete inventado | 1 | 2 (package-exists) |
1 de 1 (forzada) | StreamParser, validate_schema |
| Copilot | Paquete inventado | 1 | 2 (package-exists) |
1 de 1 (forzada) | iter_array, validate |
Total: 8 scripts analizados. 4 hallazgos reales (2 paquetes inexistentes × 2 modelos). 0 falsos positivos tras los parches.
Lo interesante es que la regla package-exists detectó los mismos dos paquetes en ambos modelos, sin importar qué API hubieran inventado. Eso es lo que hace robusta a la detección de paquetes: no depende de la creatividad del modelo, solo del nombre del paquete. Y es exactamente lo que importa para el riesgo de slopsquatting: si el paquete no existe, da igual qué método haya inventado la IA; el peligro es que alguien registre ese nombre en PyPI.
Límites, falsos positivos y el caso PyPI
Un detector que no reconoce sus límites no es un detector, es un generador de ruido. Estos son los que he encontrado hasta ahora.
Tres falsos positivos, tres lecciones de diseño.
Cuando pasé la CLI sobre los scripts de Z.ai, me encontré con 11 falsos positivos. Todos por el mismo tipo de razón: el registro no modelaba la realidad de Python.
1. os.path.join, os.path.dirname, os.path.abspath se marcaban como alucinación. No lo son: son submódulos reales de os. El problema es que mi registro trataba os como un mapa plano y no contemplaba sub-módulos. Lo corregí añadiendo path.join, path.dirname, etc. al registro.
2. requests.adapters se marcaba como paquete inexistente en PyPI. No lo es: es un submódulo de requests. El problema es que la regla consultaba PyPI con la ruta completa (requests.adapters) en lugar del paquete raíz (requests). Lo corregí para que consulte solo el primer segmento. Lección: en Python, import a.b.c no significa que exista un paquete a.b.c; significa que existe a y dentro hay un submódulo b.c.
3. urllib3.util.retry se marcaba por la misma razón que el anterior. Mismo parche.
Después de los tres parches, la CLI pasó de 11 falsos positivos a 0. Y el artículo gana credibilidad por eso: no es que la herramienta sea perfecta, es que reconoce sus errores y los corrige.
El caso PyPI, que es el más interesante.
La detección de paquetes inexistentes consulta PyPI online. En mi red de casa funcionaba. Un día cambié a una red corporativa y la regla dejó de disparar. El motivo: el DNS de esa red no resolvía ni pypi.org ni google.com. La herramienta, al no poder verificar, se saltaba la regla en silencio.
Eso es un fallo de diseño, no de red. Un detector que se calla cuando no puede verificar da falsa tranquilidad: el usuario cree que el paquete existe porque no hay hallazgo, cuando en realidad no se comprobó nada. La lección es que una herramienta que depende de red tiene que ser honesta sobre cuándo no pudo verificar. La solución que voy a implementar es un aviso visible ([warning] no se pudo verificar el paquete X: sin conexión a PyPI) y, más adelante, una caché offline de paquetes populares.
El caso se verificó en los dos sentidos. En la red corporativa, el Nivel 4 daba 4 hallazgos (la regla package-exists se saltaba). En la red buena, da 5: el quinto es fast_json_parser en data_service.py, que antes no se detectaba porque no había DNS. Eso es exactamente lo que el artículo quiere contar: la diferencia entre “no hay alucinación” y “no se pudo verificar”.
Una limitación inherente al enfoque del registro.
La regla package-exists detecta que un paquete no existe. Pero la regla api-hallucination no puede detectar las APIs inventadas de ese paquete, porque no hay un registro de referencia: el paquete no existe, así que no hay API válida contra la que comparar. Y si la librería no está en el registro (pandas, numpy, pydantic…), la función isAPIHallucination devuelve false por diseño: la herramienta solo juzga lo que conoce. Eso evita falsos positivos masivos, pero significa que la cobertura es tan buena como el registro. Ampliarlo es trabajo artesanal, y es el siguiente paso del roadmap.
Qué sigue
· Aviso visible y caché offline para PyPI (prioritario tras el caso de la red corporativa).
· Ampliar el registro a pandas y numpy, que son las que más alucinaciones generan en código de ciencia de datos. Los scripts de Z.ai usan ambas y hoy no las estoy verificando.
· Validación con código real de terceros (mi familiar en EE.UU., cuando pueda).
· JavaScript/TypeScript como segundo adaptador (roadmap, no hecho).
¿Y entonces?
Si estás generando código con IA y tus tests pasan pero algo te huele raro, prueba a mirar las llamadas a métodos que no reconoces. No hace falta una herramienta para empezar: a veces basta con abrir la documentación de la librería y comprobar que ese método existe. La herramienta automatiza eso, pero el hábito es tuyo.
Y si algo de esto te resulta útil, o si has visto alucinaciones de este tipo en tu propio código, me encantaría saberlo. Los comentarios están abiertos, y las críticas también: este proyecto se corrige con lo que encuentran otros.