P0 contexto: ventana por modelo + recuperación ante overflow + self-heal del catálogo

Que las conversaciones largas no se rompan ni gasten de más:

Ventana de contexto por modelo (antes: budget estático 120k/200k para todos):
- cost.resolve_context_window: lee context_length del catálogo OpenRouter/DeepSeek
  en Redis, con fallback a litellm. config.budget_for_window deriva el budget de
  la ventana real (window - max_output - reserve). build_context lo aplica por
  turno (param model_id) en vez del fijo de settings.
- Self-heal del catálogo OpenRouter: el admin panel lo cachea con TTL 1h y solo lo
  repuebla al abrir su ventana de IA → en runtime caducaba y se perdían ventana y
  precio. Ahora cost._get_catalog lo refresca solo (fetch público, mismo shape,
  cooldown 5min, TTL 24h). Arregla también el coste (caía al fijo).

Recuperación ante overflow:
- adapters.base.ContextOverflowError; openai_adapter traduce el error de
  context-length del proveedor (init e iteración del stream).
- base.py: retry proactivo que recompacta hasta caber en la ventana ANTES de
  llamar al LLM; si ni así cabe → error accionable (no rompe la sesión).
- engine.py: mensaje user-facing claro (modelo + ventana).

Tests: ventana/budget, self-heal (mockeado), overflow, y sesión REAL de Redis. 106 verdes.

evals/: harness para evaluar al agente acai-code (driver + README + resultados).
Comparativa kimi vs deepseek vs glm (deepseek-v4-pro high = mejor calidad/precio).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jordan
2026-06-20 13:48:19 +01:00
parent 9d11a59fb8
commit 651d61b096
15 changed files with 997 additions and 36 deletions

View File

@@ -10,8 +10,11 @@ Prioridad de fuentes de precio (para que el coste registrado en
from __future__ import annotations
import asyncio
import json
import logging
import time
import urllib.request
import redis.asyncio as redis
@@ -43,25 +46,105 @@ def _get_cfg_redis() -> "redis.Redis":
return _cfg_redis
async def _catalog_price_per_1m(model_id: str | None):
"""(price_in_1m, price_out_1m) del catálogo del panel, o None.
# --- Catálogo con self-heal -------------------------------------------------
# El catálogo OpenRouter lo publica el Forge Admin Panel con TTL de 1h y solo se
# repuebla al abrir su ventana de IA. En runtime (coste y ventana de contexto)
# eso es frágil: si caduca, perdemos precio Y context_length del modelo activo.
# Aquí lo repoblamos nosotros (fetch público a OpenRouter, mismo shape que el
# admin) cuando falta, con un cooldown para no martillear la API. DeepSeek es
# persistente (lo escribe el admin en el arranque) y no necesita self-heal.
_OPENROUTER_URL = "https://openrouter.ai/api/v1/models"
_OPENROUTER_TIMEOUT = 15
_OR_SELFHEAL_TTL = 86_400 # 24h: persiste bastante; el admin lo refresca aparte
_OR_REFRESH_COOLDOWN = 300 # como mucho un fetch / 5 min
_or_last_refresh = [0.0]
model_id viene en formato litellm ("<provider>/<id>"). Separamos el prefijo
de proveedor para elegir el cache y buscar por el id catalogado.
"""
if not model_id or "/" not in model_id:
return None
provider, _, raw_id = model_id.partition("/")
cache_key = _CACHE_KEYS.get(provider)
def _fetch_openrouter_catalog_sync() -> list[dict]:
"""GET público al catálogo OpenRouter, normalizado al MISMO shape que el
admin panel (id, context_length, price_*, supports_reasoning, supports_images).
Filtra a modelos con soporte `tools` (igual que el admin)."""
req = urllib.request.Request(_OPENROUTER_URL, method="GET")
req.add_header("Accept", "application/json")
with urllib.request.urlopen(req, timeout=_OPENROUTER_TIMEOUT) as resp:
payload = json.loads(resp.read().decode("utf-8"))
items = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(items, list):
return []
out: list[dict] = []
for it in items:
if not isinstance(it, dict) or not it.get("id"):
continue
supported = it.get("supported_parameters") or []
if not isinstance(supported, list) or "tools" not in supported:
continue
pricing = it.get("pricing") or {}
try:
pin = float(pricing.get("prompt", 0) or 0) * 1_000_000
pout = float(pricing.get("completion", 0) or 0) * 1_000_000
except (TypeError, ValueError):
pin = pout = 0.0
try:
ctx = int(it.get("context_length") or 0)
except (TypeError, ValueError):
ctx = 0
mods = (it.get("architecture") or {}).get("input_modalities") or []
out.append({
"id": it.get("id"),
"name": it.get("name") or it.get("id"),
"context_length": ctx,
"price_in_1m": pin,
"price_out_1m": pout,
"supports_reasoning": "reasoning" in supported or "include_reasoning" in supported,
"supports_images": isinstance(mods, list) and "image" in mods,
})
return out
async def _get_catalog(provider: str | None) -> list[dict] | None:
"""Catálogo del proveedor desde Redis. Para OpenRouter, si falta (TTL
caducado) lo repuebla en runtime (self-heal con cooldown)."""
cache_key = _CACHE_KEYS.get(provider or "")
if not cache_key:
return None
try:
cached = await _get_cfg_redis().get(cache_key)
if not cached:
return None
models = json.loads(cached)
if cached:
data = json.loads(cached)
if isinstance(data, list):
return data
except Exception as e: # pragma: no cover - defensivo
logger.warning("catálogo %s no disponible para coste: %s", provider, e)
logger.warning("catálogo %s no disponible: %s", provider, e)
if provider != "openrouter":
return None
# Self-heal solo para OpenRouter, con cooldown para no martillear la API.
now = time.time()
if now - _or_last_refresh[0] < _OR_REFRESH_COOLDOWN:
return None
_or_last_refresh[0] = now
try:
models = await asyncio.to_thread(_fetch_openrouter_catalog_sync)
except Exception as e:
logger.warning("self-heal catálogo openrouter falló: %s", e)
return None
if models:
try:
await _get_cfg_redis().set(cache_key, json.dumps(models), ex=_OR_SELFHEAL_TTL)
logger.info("catálogo openrouter repoblado en runtime: %d modelos", len(models))
except Exception:
pass
return models
return None
async def _catalog_price_per_1m(model_id: str | None):
"""(price_in_1m, price_out_1m) del catálogo, o None. model_id en formato
litellm ("<provider>/<id>")."""
if not model_id or "/" not in model_id:
return None
provider, _, raw_id = model_id.partition("/")
models = await _get_catalog(provider)
if not models:
return None
for m in models:
if m.get("id") == raw_id:
@@ -72,6 +155,59 @@ async def _catalog_price_per_1m(model_id: str | None):
return None
# --- Ventana de contexto por modelo -----------------------------------------
# Cache en proceso con TTL corto: build_context resuelve la ventana en cada step
# del loop, y el catálogo cambia rara vez. Evita pegar a Redis 25x/turno.
_window_cache: dict[str, tuple[float, int | None]] = {}
_WINDOW_TTL = 60.0
async def resolve_context_window(model_id: str | None) -> int | None:
"""Ventana de contexto (tokens) del modelo activo.
Fuentes en orden: catálogo del Forge Admin Panel en Redis (`context_length`)
→ price/info map de LiteLLM (`max_input_tokens`/`max_tokens`) → None.
`model_id` viene en formato litellm ("<provider>/<id>").
"""
if not model_id or "/" not in model_id:
return None
now = time.time()
cached = _window_cache.get(model_id)
if cached and (now - cached[0]) < _WINDOW_TTL:
return cached[1]
window: int | None = None
# 1. Catálogo del panel (con self-heal para OpenRouter si caducó).
provider, _, raw_id = model_id.partition("/")
models = await _get_catalog(provider)
if models:
for m in models:
if m.get("id") == raw_id:
cl = m.get("context_length")
if isinstance(cl, int) and cl > 0:
window = cl
break
# 2. Fallback: LiteLLM conoce muchos modelos (deepseek/, anthropic/, ...).
if window is None:
try:
import litellm
info = litellm.get_model_info(model_id) or {}
for key in ("max_input_tokens", "max_tokens"):
v = info.get(key)
if isinstance(v, int) and v > 0:
window = v
break
except Exception:
pass
_window_cache[model_id] = (now, window)
return window
async def compute_cost(model_id: str | None, input_tokens: int, output_tokens: int) -> dict:
"""Coste de una ejecución para `model_id` y los tokens dados.