ci: pinea anthropic y corre la suite DENTRO de la imagen
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m6s

Tres piezas de un mismo agujero: ningún test de este repo había visto nunca las
librerías que ejecuta el bot.

1. **El pin.** `anthropic>=0.40.0` era el ÚNICO suelo del fichero — todo lo demás
   ya estaba pinneado exacto, y medido con `pip freeze` dentro del pod, todo lo
   pinneado cuadra. Ese suelo dejó entrar la 1.2.0, una versión MAYOR, sin un
   commit: quitó `temperature` de `messages.create` y rompió el reintento del SEO
   (32f0b2c). 1.2.0 es lo que ya corre, así que el pin no cambia producción:
   la escribe. Evidencia de compatibilidad del propio pod: convive con
   httpx 0.28.1, pydantic 2.13.4 y aiohttp 3.14.1.

2. **La suite en la CI, dentro de la imagen construida.** `Dockerfile.test` parte
   del tag recién publicado, así que los tests ven EXACTAMENTE lo que se
   despliega. Va antes de actualizar los manifiestos: si falla, no se despliega.
   `tests/` no hace falta copiarlo, el runtime ya hace `COPY . .`.
   `TELEGRAM_BOT_TOKEN` de mentira porque es obligatorio y en la imagen no hay
   `.env` — comprobado que la suite entera pasa sólo con eso.

3. **El barrido, que cierra la clase y no el caso.** `tests/test_sdk_contract.py`
   lee con `ast` TODAS las llamadas a `messages.create` del árbol y compara sus
   kwargs con la firma del SDK instalado. Comprobar sólo el sitio arreglado
   habría dejado los otros ocho a ciegas.

⚠️ Los dos guardianes nuevos NO pueden fallar en un portátil con anthropic
0.102.0: ahí `temperature` sigue en la firma. Verificados a mano dentro del pod
contra 1.2.0 — inyectando la avería en una copia aislada, el barrido la caza; con
el código real, las nueve llamadas pasan. Es el pin + la CI lo que los convierte
en pruebas y no en gestos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EHakatofHeJAzdXL26Q5bq
This commit is contained in:
ChemaVX
2026-09-02 08:58:14 +00:00
co-authored by Claude Opus 5
parent 32f0b2c1ab
commit 6cbb7280fa
5 changed files with 133 additions and 5 deletions
+73
View File
@@ -0,0 +1,73 @@
"""Lo que le pedimos al SDK de Anthropic contra lo que el SDK acepta.
Este fichero existe por un fallo concreto: `src/seo/autofill.py` mandaba
`temperature=0.0` a `messages.create`, y anthropic 1.2.0 lo quitó de la firma
—los parámetros de muestreo se movieron a `output_config`—. La llamada moría
con un TypeError que un `except` convertía en aviso, así que el reintento
estricto del SEO llevaba meses sin ejecutarse. Nada lo vio: la importación no
falla, ningún test toca la API de verdad, y la suite corría en un portátil con
anthropic 0.102.0 mientras el pod ejecutaba 1.2.0.
Comprobar el caso arreglado no habría bastado. Lo que hace falta es cerrar la
CLASE: se leen TODAS las llamadas del árbol con `ast` y se comparan sus kwargs
con la firma del SDK instalado. Un kwarg que el SDK ya no acepta falla aquí, en
el sitio donde está escrito, sin que nadie tenga que ejercitar ese camino.
Junto con el pin de `requirements.txt` y el `pytest` que la CI corre DENTRO de
la imagen, esto pasa a ejecutarse contra el SDK que de verdad se despliega —
que es lo único que lo convierte en una prueba y no en un gesto.
"""
from __future__ import annotations
import ast
import inspect
from pathlib import Path
import pytest
SRC = Path(__file__).resolve().parents[1] / "src"
def _llamadas_al_sdk() -> list[tuple[str, int, list[str], bool]]:
"""(fichero, línea, kwargs, tiene_**kwargs) de cada `*.messages.create(...)`."""
encontradas = []
for fichero in sorted(SRC.rglob("*.py")):
arbol = ast.parse(fichero.read_text(encoding="utf-8"), filename=str(fichero))
for nodo in ast.walk(arbol):
if not isinstance(nodo, ast.Call):
continue
fn = nodo.func
if not (isinstance(fn, ast.Attribute) and fn.attr == "create"
and isinstance(fn.value, ast.Attribute)
and fn.value.attr == "messages"):
continue
nombres = [k.arg for k in nodo.keywords if k.arg is not None]
estrella = any(k.arg is None for k in nodo.keywords)
encontradas.append((str(fichero.relative_to(SRC)), nodo.lineno,
nombres, estrella))
return encontradas
def _firma() -> set[str]:
from anthropic import AsyncAnthropic
return set(inspect.signature(
AsyncAnthropic(api_key="test").messages.create).parameters)
def test_hay_llamadas_que_revisar():
"""Un barrido que no encuentra nada mide una lista vacía y pasa siempre."""
assert len(_llamadas_al_sdk()) >= 5
@pytest.mark.parametrize("fichero,linea,kwargs,estrella", _llamadas_al_sdk(),
ids=lambda v: str(v))
def test_cada_llamada_usa_kwargs_que_el_sdk_acepta(fichero, linea, kwargs, estrella):
if estrella and not kwargs:
# Los kwargs se construyen aparte (p. ej. `_create_kwargs`): esa función
# tiene su propio test, que compara el diccionario ya montado.
pytest.skip(f"{fichero}:{linea} pasa **kwargs; cubierto en su propio test")
firma = _firma()
desconocidos = sorted(k for k in kwargs if k not in firma)
assert desconocidos == [], (
f"{fichero}:{linea} pasa {desconocidos} a messages.create y el SDK "
f"instalado no lo acepta. Arregla la llamada, no relajes el assert.")