AutoPlan OpenRouter — Fixes C1+C2+C3 Audit s49 (varianza Vertex)
Contexto
Durante la sesión de auditoría s49 se detectó varianza no determinista en las respuestas de Google Vertex AI servidas a través de OpenRouter en el flujo de análisis de planos (AutoPlanService). La varianza se manifestaba como JSONs malformados con patrones distintos dependiendo del provider que terminaba sirviendo la petición (efecto fallback silencioso), y como seeds que no se incrementaban correctamente entre reintentos.
El PR #19 aplica tres correcciones ortogonales (C1, C2, C3) sobre los archivos blueprints/services/autoplan.py y blueprints/services/openrouter_driver.py.
Fix C1 — Seed explícito por intento (openrouter_driver.py)
Problema
La variable seed se reutilizaba entre intentos de retry sin gestionar correctamente el caso seed=None. Cuando la variable no se establecía en el primer intento, el campo seed podía quedar en create_kwargs de iteraciones anteriores.
Solución
seedrenombrada abase_seedpara dejar claro que es el valor base.- Cada iteración calcula
effective_seed = base_seed + json_attempty lo aplica (o lo elimina decreate_kwargssibase_seed is None). - El seed efectivo se loguea explícitamente en cada intento, facilitando la reproducción de glitches.
# Antes
seed = int(seed_raw) if seed_raw.strip() else None
# ...
if json_attempt > 0 and seed is not None:
create_kwargs["seed"] = seed + json_attempt
# Después
base_seed = int(seed_raw) if seed_raw.strip() else None
# ...
effective_seed = (base_seed + json_attempt) if base_seed is not None else None
if effective_seed is not None:
create_kwargs["seed"] = effective_seed
elif "seed" in create_kwargs:
del create_kwargs["seed"]
Fix C2 — Control de fallback de provider y require_parameters (openrouter_driver.py)
Problema
La configuración allow_fallbacks: True en el body de la petición OpenRouter permitía que, si Vertex (o el provider primario) no estaba disponible, la petición se derivase silenciosamente a otro provider con distinta distribución de outputs. Esto invalidaba las seeds y generaba respuestas con patrones de glitch diferentes, dificultando el debugging.
Solución
- Nuevo env var
OPENROUTER_ALLOW_FALLBACKS(default:"false"). - Añadido
require_parameters: Trueen el body del provider: rechaza providers que no soporten los parámetrosseed/top_k, evitando que un provider alternativo sirva la petición sin semilla.
# Nuevo env var
allow_fallbacks_raw = (os.environ.get("OPENROUTER_ALLOW_FALLBACKS") or "false").lower().strip()
allow_fallbacks = allow_fallbacks_raw in ("true", "1", "yes")
# Body actualizado
"extra_body": {
"top_k": 64,
"provider": {
"order": provider_order,
"allow_fallbacks": allow_fallbacks, # ← antes hardcoded True
"require_parameters": True, # ← nuevo
},
},
Configuración de producción recomendada:
| Variable | Valor recomendado | Efecto |
|---|---|---|
OPENROUTER_ALLOW_FALLBACKS | false | Fuerza uso exclusivo del provider primario |
OPENROUTER_PROVIDER_ORDER | Vertex AI | Provider principal para Gemma 4 |
Log diagnóstico defensivo
El bloque de log ahora está envuelto en try/except Exception para no propagar errores de introspección del objeto response. Además, se extrae el campo provider con fallback encadenado (getattr → model_dump → model_extra) porque la SDK de OpenAI no garantiza el campo en el objeto raíz.
provider_used = (
getattr(response, "provider", None)
or raw_dump.get("provider")
or (getattr(response, "model_extra", None) or {}).get("provider")
or "unknown"
)
También se detecta y alerta finish_reason == "length" (respuesta truncada por límite de tokens).
Fix C3 — Cleanup LLM JSON en cascada: basic → aggressive (autoplan.py + openrouter_driver.py)
Problema
El método único _clean_llm_json aplicaba indiscriminadamente regex agresivas (diseñadas para reparar glitches de Gemma 4) sobre todo JSON, incluyendo JSONs originalmente válidos. La regex de recuperación de claves sin comilla de cierre ("x2: 380) disparaba falsos positivos en claves string con : dentro de su valor.
Solución
Split en dos métodos estáticos + alias de compatibilidad:
_clean_llm_json_basic(text: str) → str
Transformaciones no destructivas — seguras sobre cualquier JSON:
- Strip de bloques markdown (
```json ... ```). - Strip de bloques thinking (
<think>,<thinking>,<|think|>,<|thinking|>). - Slice al primer
{/ último}. - Fix de trailing commas (
,},,]). - Normalización de smart quotes (
"→",'→').
_clean_llm_json_aggressive(text: str) → str
Reparaciones específicas de glitches Gemma 4 — pueden corromper JSON válido:
- Recuperación de clave con comilla de cierre faltante (
"x2: 380→"x2": 380).
Mejorada con lookahead positivo: sólo dispara si el valor siguiente es un literal JSON válido (\d,-,",[,{,t,f,n). - Recuperación de clave completa faltante (patrón
s49 Vertex 2304px).
_clean_llm_json(text: str) → str (alias de compat)
Aplica basic → aggressive en secuencia, manteniendo paridad con el comportamiento pre-audit para callers existentes (Ollama, DeepSeek).
Flujo de retry en openrouter_driver.py
texto LLM recibido
│
▼
_clean_llm_json_basic() ← no destructivo, siempre
│
├─ json.loads OK ──────────► return resultado
│
└─ JSONDecodeError
│
▼
_clean_llm_json_aggressive() ← sólo en fallo
│
├─ json.loads OK ──► return resultado
│
└─ JSONDecodeError
│
├─ json_attempt < max_retries ──► retry con seed+1
│
└─ json_attempt == max_retries ──► raise + log ERROR
Variables de entorno relevantes
| Variable | Default | Descripción |
|---|---|---|
OPENROUTER_ALLOW_FALLBACKS | false | Permite fallback a otros providers si el primario falla |
OPENROUTER_PROVIDER_ORDER | "" | Lista separada por comas de providers en orden preferente |
OPENROUTER_MODEL | google/gemma-4-26b-a4b-it | Modelo a usar |
OPENROUTER_SEED | "" | Seed base (se incrementa en retries) |
OPENROUTER_IMAGE_DETAIL | auto | Nivel de detalle de imagen (auto, high, low) |
Impacto y retrocompatibilidad
- Sin breaking changes:
_clean_llm_jsonse mantiene como alias; callers legacy (Ollama, DeepSeek) no se modifican. - Comportamiento en producción con Vertex: con
OPENROUTER_ALLOW_FALLBACKS=falseyrequire_parameters=True, cualquier fallo de Vertex genera un error explícito en lugar de derivar silenciosamente, facilitando alertas y debugging. - Reproducibilidad: el seed efectivo ahora aparece en cada línea de log, permitiendo reproducir exactamente el mismo intento fallido.
Véase también
- [[entity—blueprints—model—blueprint]]
- [[entity—blueprints—model—aiprompt]]