CreaRack-SL

Auto-Plan: Resiliencia OpenRouter — retry JSONDecodeError + logging diagnóstico + regex clave faltante

Auto-Plan: Resiliencia OpenRouter — retry, logging diagnóstico y regex de clave faltante

Introducido en PR #18 (commit 25ec057, 2026-05-04). Afecta a dos módulos del pipeline de análisis LLM de Auto-Plan:

  • blueprints/services/openrouter_driver.py — cliente OpenRouter vía openai SDK
  • blueprints/services/autoplan.py — utilidades de limpieza de JSON generado por LLMs

Motivación

Los modelos LLM servidos vía OpenRouter (en particular Gemma 4 en salidas largas, footgun documentado en s46/s49) presentaban dos clases de fallos silenciosos o abruptos:

  1. JSONDecodeError no recuperable: el modelo producía JSON malformado; la llamada fallaba sin reintentar, devolviendo error al usuario.
  2. Clave JSON faltante (bug s49 Vertex 2304px): en objetos de anotación, el campo "y" / "y1" / "y2" aparecía como ": 700 (la clave se omitía). json.loads fallaba en estos casos aunque el resto del JSON fuera válido.

Además, no existía logging del provider real que sirvió la petición ni del coste asociado, dificultando el diagnóstico post-mortem.


Cambios implementados

1. Retry loop con seed alteration (openrouter_driver.py)

max_json_retries = 2
for json_attempt in range(max_json_retries + 1):
    if json_attempt > 0 and seed is not None:
        create_kwargs["seed"] = seed + json_attempt
    response = client.chat.completions.create(**create_kwargs)
    ...
    try:
        return json.loads(cleaned)
    except json.JSONDecodeError as exc:
        if json_attempt < max_json_retries:
            logger.warning(...)
            continue
        logger.error(...)
        raise
  • Hasta 3 intentos en total (1 original + 2 retries).
  • En cada retry el seed se incrementa en +1 para evitar reproducir el mismo glitch determinista del modelo.
  • Si los 3 intentos fallan, se relanza el JSONDecodeError original (nunca se silencia el error final).

2. Logging diagnóstico por llamada

Cada intento al modelo registra en logger.info:

CampoFuente
providerresponse.provider (field extra de OpenRouter)
prompt_tokensresponse.usage.prompt_tokens
completion_tokensresponse.usage.completion_tokens
cached_tokensresponse.usage.prompt_tokens_details.cached_tokens
costresponse.usage.cost (formateado como $X.XXXXXX)
response charslen(text_response)

Ejemplo de línea de log:

[AutoPlan] OpenRouter served by Google (retry 1) | tokens: 2048p+512c (cached=1024) | cost=$0.000312 | response=4200 chars

El campo provider es devuelto por la API de OpenRouter y refleja el backend real (Google, AWS Bedrock, Azure…), lo que es crítico para correlacionar glitches con proveedores específicos.

3. Regex de recuperación de clave faltante (autoplan.py → _clean_llm_json)

text = re.sub(
    r'("(x\d*)":\s*-?\d+(?:\.\d+)?,)\s*":',
    lambda m: f'{m.group(1)} "y{m.group(2)[1:]}":',
    text,
)

Patrón detectado (s49):

"text": "PLANTA M0", "x": 140, ": 700, "color": "blue"

→ la clave "y" fue omitida por el modelo.

Lógica de reparación: en el dominio Auto-Plan, después de "x" / "x1" / "x2" siempre sigue "y" / "y1" / "y2". El regex reconstruye la clave faltante usando el sufijo numérico del campo x correspondiente.

⚠️ Esta reparación es domain-specific (coordenadas de anotaciones 2D). No aplicar a otros contextos JSON sin validar la invariante x → y.


Comportamiento observable

Caso normal (sin retry)

[AutoPlan] OpenRouter served by Google | tokens: 1800p+420c (cached=0) | cost=$0.000240 | response=3100 chars

→ json.loads OK → devuelve resultado.

Caso glitch recuperado (1 retry)

[AutoPlan] OpenRouter served by Google | tokens: 1800p+420c (cached=0) | cost=$0.000240 | response=3100 chars
[AutoPlan] JSON parse failed at char 1542 (retry 1/2 with altered seed); raw[...] = '...'
[AutoPlan] OpenRouter served by Google (retry 1) | tokens: 1800p+435c (cached=0) | cost=$0.000245 | response=3110 chars

→ Segundo intento OK → resultado transparente para el usuario.

Caso irrecuperable (3 fallos)

[AutoPlan] OpenRouter JSON parse FINAL failure at char 1542; raw[...] = '...'
RuntimeError: OpenRouter retries exhausted; last=<JSONDecodeError>

→ Se propaga la excepción al endpoint /api/blueprints/autoplan/import.


Archivos afectados

ArchivoTipo de cambioLOC aprox.
blueprints/services/openrouter_driver.pyRefactor bucle + logging+53 / -17
blueprints/services/autoplan.pyNuevo regex en _clean_llm_json+11

Consideraciones operativas

  • Coste duplicado en retries: cada retry consume tokens adicionales. Con max_json_retries=2 el coste máximo es ×3 el nominal. Monitorizar en producción si hay spikes de cost en los logs.
  • Seed alteration: la variación de seed (+1, +2) es suficiente para salir de un glitch determinista, pero no garantiza JSON válido. Si el problema persiste, verificar si el modelo subyacente ha cambiado de versión en el router.
  • Provider field: response.provider es un campo no estándar de OpenRouter. Si se cambia de backend (p.ej. directo a Vertex), este campo puede devolver "unknown". El log sigue siendo funcional gracias al fallback getattr(response, "provider", "unknown").
  • Regex orden de aplicación: el nuevo regex se aplica antes que la traducción de comillas tipográficas (str.maketrans). El orden es relevante porque el regex asume comillas ASCII.

Véase también

  • [[entity—blueprints—service—autoplan]]
  • [[entity—blueprints—service—openrouter-driver]]
  • [[entity—blueprints—endpoint—autoplan-import]]
  • [[entity—blueprints—model—aiprompt]]
  • [[concept—autoplan—llm-json-repair]]