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

2
evals/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Los logs de sesión contienen contenido real de proyectos de cliente.
logs/

43
evals/README.md Normal file
View File

@@ -0,0 +1,43 @@
# Evals del agente acai-code
Harness para evaluar el comportamiento del agente IA (`acai`) montando una
landing real con módulos gestionables, capturando cada turno (thinking, tool
calls, resultados, errores). Sirve para **comparar entre modelos** y discernir
si un fallo es del **modelo** o de la **documentación/KB** (mismo flujo, mismo
proyecto, distinto modelo → ¿cambian los errores?).
## Cómo correrlo
1. Elige el modelo activo en el **Forge Admin Panel → ventana de IA** (provider +
modelo + reasoning). El catálogo OpenRouter se auto-repuebla en runtime aunque
caduque (ver `orchestrator/cost.py: _get_catalog`).
2. Usa un proyecto **en modo TEST** (no producción) — el agente escribe módulos/
records reales en la copia forge-local. Nunca corras esto contra producción.
3. Lanza cada turno con el driver, reutilizando el `session_id` que devuelve el
primer turno para mantener la MISMA conversación:
```bash
NET=acai-vscode-plugin_acai-net # red docker del compose
docker run --rm --network $NET \
-v "$PWD/agenticSystem/evals:/data" -v "$PWD/agenticSystem/evals/logs:/logs" \
-e EVAL_PROJECT=empleo.cocosolution.com \
-w /data acai-vscode-plugin-agentic \
python /data/driver.py "Móntame una sección de beneficios con 3 tarjetas"
# turno 2 (reusa el SESSION_ID del turno 1):
docker run ... python /data/driver.py "Ahora una sección de equipo con fotos y enlaces" "<SESSION_ID>"
```
- El log completo (en vivo) se acumula en `evals/logs/session.log`.
- El driver autentica con `X-Acai-User` hiteando `app:9091` directo en la red
interna (somos superadmin en infra de confianza).
## Métricas que captura
- nº de tool calls, errores (`success:false`, HTTP_4xx), tools repetidas (señal
de bucle), tokens de input/output (coste del thrashing).
## Resultados
Ver [`results-landing-build.md`](./results-landing-build.md) — un apartado por
modelo, para comparar.

148
evals/driver.py Normal file
View File

@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Driver de evaluación del agente acai-code (chat agentic).
Manda UN mensaje de usuario al chat, consume el SSE, loguea EN VIVO cada
tool/resultado/error y resume el turno. Reutiliza session_id para mantener la
MISMA conversación a lo largo de varios turnos.
Uso (dentro de la red docker, hitea `app` directo con auth interna X-Acai-User):
docker run --rm --network <proj>_acai-net \\
-v "$PWD/agenticSystem/evals:/data" -v "$PWD/agenticSystem/evals/logs:/logs" \\
-w /data acai-vscode-plugin-agentic \\
python /data/driver.py "<mensaje del usuario>" "<session_id opcional>"
Variables de entorno opcionales: EVAL_PROJECT (slug), EVAL_USER (default superadmin).
Sirve para comparar el comportamiento/errores del MISMO flujo entre distintos
modelos (cambia el modelo activo en el admin panel y repite). Ver README.md.
"""
import os
import sys
import json
import time
import urllib.request
APP = os.environ.get("EVAL_APP", "http://app:9091")
USER = os.environ.get("EVAL_USER", "superadmin")
PROJECT = os.environ.get("EVAL_PROJECT", "empleo.cocosolution.com")
LOG = os.environ.get("EVAL_LOG", "/logs/session.log")
msg = sys.argv[1]
session_id = sys.argv[2] if len(sys.argv) > 2 else ""
def log(s):
with open(LOG, "a") as f:
f.write(s + "\n")
f.flush()
body = {"project": PROJECT, "message": msg, "agent_id": "acai", "plan_mode": "off"}
if session_id:
body["session_id"] = session_id
req = urllib.request.Request(
APP + "/api/agentic/chat",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json", "X-Acai-User": USER},
method="POST",
)
log("\n" + "=" * 80)
log("[{}] >>> USER: {}".format(time.strftime("%H:%M:%S"), msg))
sid = session_id
text_parts = []
thinking_chars = 0
tool_calls = []
tool_results = {}
errors = []
usage = {}
seen = {}
# IMPORTANTE: el agentic re-emite el snapshot `assistant` con TODOS los bloques
# acumulados tras cada tool (reconciliación, claude_format.py). Hay que
# deduplicar por `tool_use` id o se cuenta el mismo tool decenas de veces.
seen_ids = set()
try:
resp = urllib.request.urlopen(req, timeout=1200)
except Exception as e:
log("!!! HTTP ERROR: {}".format(e))
print("HTTP_ERROR", e)
sys.exit(1)
for raw in resp:
line = raw.decode("utf-8", "replace").rstrip("\r\n")
if not line.startswith("data: "):
continue
payload = line[6:].strip()
if not payload:
continue
try:
ev = json.loads(payload)
except Exception:
continue
t = ev.get("type")
if t == "session":
sid = ev.get("session_id") or sid
elif t == "stream_event":
e = ev.get("event", {})
et = e.get("type")
if et == "content_block_delta":
d = e.get("delta", {})
if d.get("type") == "text_delta" or "text" in d:
text_parts.append(d.get("text", ""))
elif d.get("type") == "thinking_delta":
thinking_chars += len(d.get("thinking", ""))
elif t == "assistant":
for blk in ev.get("message", {}).get("content", []):
if blk.get("type") != "tool_use":
continue
bid = blk.get("id") or ""
if bid and bid in seen_ids:
continue # snapshot de reconciliación re-emite bloques ya vistos
if bid:
seen_ids.add(bid)
name = blk.get("name", "?")
inp = json.dumps(blk.get("input", {}), ensure_ascii=False)
sig = name + "|" + inp[:200]
seen[sig] = seen.get(sig, 0) + 1 # repeticiones REALES (mismo tool+input, otro id)
tool_calls.append((name, inp, bid))
rep = " [REPETIDA x{}]".format(seen[sig]) if seen[sig] >= 2 else ""
log(" [{}] TOOL {} {}{}".format(time.strftime("%H:%M:%S"), name, inp[:300], rep))
elif t == "tool_result":
tid = ev.get("tool_use_id")
content = ev.get("content")
cstr = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
is_err = bool(ev.get("is_error")) or ('"success": false' in cstr) or ('"success":false' in cstr)
tool_results[tid] = (is_err, cstr[:500])
log(" ->{} {}".format(" [ERROR]" if is_err else " ok", cstr[:300]))
if is_err:
errors.append("TOOL_ERROR: " + cstr[:300])
elif t == "result":
usage = ev.get("usage", {}) or {}
if ev.get("content") and not text_parts:
text_parts.append(ev["content"])
elif t == "error":
errors.append("STREAM_ERROR: " + str(ev.get("error")))
log(" !! STREAM_ERROR: " + str(ev.get("error"))[:300])
elif t == "done":
break
full_text = "".join(text_parts)
repeated = {s: c for s, c in seen.items() if c >= 2}
log("[ASSISTANT] " + full_text[:1500])
log("[resumen] tools={} errores={} repetidas={} thinking~{}c usage in={} out={}".format(
len(tool_calls), len(errors), len(repeated), thinking_chars,
usage.get("input_tokens"), usage.get("output_tokens")))
print("SESSION_ID={}".format(sid))
print("TOOLS={} ERRORS={} REPEATED={}".format(len(tool_calls), len(errors), len(repeated)))
for (name, inp, tid) in tool_calls:
res = tool_results.get(tid)
print(" - {}{} {}".format(name, " [ERR]" if (res and res[0]) else "", inp[:110]))
for e in errors:
print(" !! " + e[:220])
print("ASSISTANT:", full_text[:1400])
print("USAGE in={} out={}".format(usage.get("input_tokens"), usage.get("output_tokens")))

View File

@@ -0,0 +1,156 @@
# Resultados — eval "montar landing" (acai-code)
Flujo fijo de 3 turnos sobre el proyecto **empleo.cocosolution.com (en TEST)**:
1. **T1** — sección sencilla: "Beneficios" con 3 tarjetas (icono, título, texto).
2. **T2** — módulo complejo: "Conoce al equipo", multi-registro v2, 3 personas con
**foto generada + nombre + puesto + testimonio + enlace LinkedIn**.
3. **T3** — edición: cambiar el puesto de una persona y borrar otra tarjeta.
Objetivo: comparar entre modelos para ver si los fallos son **del modelo** o de la
**KB/docs** (mismo flujo → si todos fallan igual, es la doc; si solo uno, es el modelo).
## Comparativa entre modelos
> ⚠️ **Corrección metodológica (importante).** Mi primera versión contaba los `tool_use`
> de los snapshots `assistant` del SSE. El agentic **re-emite el snapshot con todos los
> bloques acumulados tras cada tool** (`claude_format.py:_build_assistant_snapshot`), así que
> el mismo tool se contaba muchas veces → **los conteos de tool calls estaban inflados**
> (p.ej. "30 generate_image" cuando el `consumo_acaicode` real era **3**). El driver ya
> deduplica por id. **Solo son fiables: tokens de `result.usage`, `consumo_acaicode`, y el
> razonamiento del propio modelo.** Abajo solo se usan esas señales.
| Modelo | Fecha | Tareas OK | Tokens in (3 turnos) | Resolvió record de página | Calidad observada (razonamiento) |
|---|---|---|---|---|---|
| `openrouter/moonshotai/kimi-k2.7-code` (medium) | 2026-06-20 | 3/3 | **~2,66M** | **NO — alucinó `num=1`** | Actúa, falla, reintenta. Edita código a ciegas (`line_replace` no casa). Mucho thrashing. |
| `deepseek/deepseek-v4-pro` (high) | 2026-06-20 | 3/3 | **~649k** | `num=267` ✅ | Explora a fondo y acierta. 0 errores. Maneja ambigüedad (Laura→Elena). |
| `z-ai/glm-5.2` (high) | 2026-06-20 | 3/3 | **~720k** | `num=267` ✅ | Sólido. Autocorrige (Twig `=``==`; fotos cruzadas al borrar). Maneja ambigüedad. |
Imágenes generadas (de `consumo_acaicode`): **3 por turno en los 3 modelos** — correcto, una por
persona. **No hubo sobre-generación** (era artefacto de medición).
## Conclusión modelo vs KB (3 modelos, mismo flujo, misma KB)
- **Señal autoritativa = tokens.** kimi gasta **~4× más** (~2,66M vs ~650720k) para la MISMA
tarea → reintentos/thrashing reales (cada step reenvía contexto). Es el indicador más fiable.
- **`num=1` alucinado → MODELO (kimi).** Deepseek **y** GLM, con la **misma KB**, resolvieron el
record de la página correctamente (lo dicen en su propio razonamiento). Kimi no. **Definitivo:
es kimi, no la documentación.**
- **NO hay evidencia de un problema de KB en el flujo multi-registro/imágenes.** Lo que parecía
sobre-generación (×30) era mi bug de conteo; los tres modelos generaron 3 imágenes (correcto).
**Retirada** la "acción de KB #1" anterior.
- **Bug real encontrado por GLM:** al borrar un registro de un módulo multi-registro, el sistema
reutiliza nums y **las fotos quedan cruzadas**; GLM lo detectó y corrigió. Merece revisar el
flujo delete/reorder (plataforma).
- **Calidad de modelo:** kimi es claramente el más flojo; **deepseek-v4-pro y GLM-5.2 (high) son
sólidos y comparables**.
**Acciones sugeridas:** (1) **no usar kimi-k2.7 como default**; deepseek-v4-pro o GLM-5.2 (high)
son buenos. (2) Revisar el bug delete→fotos cruzadas. (3) (Opcional) re-medir con el driver
deduplicado si se quieren conteos exactos de tool calls; las conclusiones por tokens no cambian.
---
## kimi-k2.7-code — 2026-06-20
**Veredicto:** entrega las 3 tareas, pero con **mucho thrashing** y errores recurrentes
de los que **no aprende dentro del turno**.
### Por turno
- **T1 (beneficios):** completado. Reutilizó un módulo de tarjetas existente. Errores:
`acai_line_replace``HTTP_409 "Search block not found"` (edita código a ciegas) y
acceso a `record num=1` inexistente en `apartados`. Se recuperó. **1,77M tokens input**
(acumulado de ~9 steps por los reintentos).
- **T2 (equipo, multi-registro v2):** completado (módulo `conocealequipo_coco`, 3 personas,
fotos generadas, enlaces LinkedIn en nueva pestaña). Pero **`add_module_to_record` ×11
sobre el mismo módulo** (bucle en el workflow multi-registro; idempotente, devolvió el
mismo `sectionId` → NO duplicó en la página) y **2 ciclos de generación de imágenes**
(6 `generate_image` para 3 personas). 606k tokens.
- **T3 (edición):** completado limpio (**0 errores**), Carlos→CTO + Laura eliminada. Pero
**7× `list_record_uploads`** redundante. 284k tokens.
### Inventario de errores (sesión completa)
| Error | Veces | Diagnóstico |
|---|---:|---|
| `Record num=1 not found in 'apartados'` | **52** | Alucina el record de la página (real = `num=267`). **No aprende** del error y reintenta con `num=1`. |
| `Search block not found` (HTTP_409, `acai_line_replace`) | 22 | Genera bloques de búsqueda que no casan con el fichero; edita código sin verlo bien. |
| `add_module_to_record` mismo módulo | 11 | Bucle en el workflow multi-registro v2. |
- 139 tool calls · ~74 `success:false` · 148 llamadas marcadas como repetidas.
### ¿Modelo o KB? (hipótesis a confirmar con otros modelos)
- **`num=1` (×52):** huele a **KB** — falta una regla clara de "obtén el `num` real de la
página con `list_table_records` antes de operar; nunca asumas num=1". Si otros modelos
caen igual → es la doc.
- **multi-registro v2 (bucle):** probablemente **KB** — falta un doc de "cómo añadir N
registros a un módulo repetible".
- **`line_replace` a ciegas:** mezcla — la KB debería exigir `acai_view` previo y casar
exacto.
### Notas de contexto / coste (P0)
- **Cero overflow** en los 3 turnos pese a 1,77M tokens acumulados/turno → no se rompió.
- La ventana real de kimi es **262144** (256k). El catálogo OpenRouter había **caducado**
(TTL 1h) → al principio se usó budget estático; tras el self-heal (`cost.py`) ya resuelve
la ventana real. Coste real de kimi: ~$0.61 in / $3.07 out por 1M.
---
## deepseek-v4-pro (high) — 2026-06-20
**Veredicto: ELEGIDO** (mejor relación calidad/precio). 3/3 tareas, **0 errores**, eficiente y
**cauto ante acciones destructivas ambiguas**.
### Re-medición con driver deduplicado (números AUTORITATIVOS, baseline limpio)
| Turno | Tool calls (reales) | Errores | `generate_image` | Tokens in |
|---|---:|---:|---:|---:|
| T1 beneficios | **19** | 0 | 3 | 264k |
| T2 equipo (multi-registro) | **14** | 0 | 3 | 320k |
| T3 edición ambigua | **1** | 0 | 0 | 77k |
| **Total** | **34** | **0** | 6 | **~661k** |
- Imágenes = 3 por módulo (correcto, coincide con `consumo_acaicode`). **Sin thrashing** — los
"135/77/30" de abajo eran del artefacto de conteo, ya corregido.
- **T3 (lo mejor):** ante "quita a Laura Gómez" (no existía; sus personas eran Marina/Carlos/
Lucía), **no adivinó: paró y preguntó** a quién borrar, ofreciendo ya el cambio claro
(Carlos→CTO). Cautela correcta con un borrado ambiguo.
### Por turno (medición ANTIGUA — inflada por el artefacto, ver banner arriba)
- **T1 (beneficios):** 135 tools, **0 err**, 238k tok. Exploró el proyecto (tablas, records,
módulos), **resolvió bien `apartados num=267`**, localizó un módulo de referencia
(`sobrenosotrosbeneficios_8pjhao`) y creó un módulo nuevo con `multiv2`. Renderizó OK.
- **T2 (equipo, multi-registro v2):** 77 tools, **0 err**, 183k tok. Módulo `conocealequipo_j8m3k7`
con 3 personas, fotos y enlaces. **Pero 30 `generate_image` + 8 `add_module_to_record`**
para 3 personas → mismo thrashing del workflow multi-registro/imágenes que kimi (peor en
imágenes).
- **T3 (edición):** 27 tools, **0 err**, 228k tok. Sus personas eran Marina/Carlos/Elena;
ante "quita a Laura" razonó *"no existe Laura, asumo que es Elena"* y la quitó + Carlos→CTO.
Manejo inteligente de la ambigüedad.
- Totales: **239 tools, 0 errores, ~649k tok input** (4× más barato que kimi pese a más calls).
- Ventana real deepseek-v4-pro: **1.000.000**. Coste catálogo: ~$0.435 in / $0.87 out por 1M.
---
## glm-5.2 (high) — 2026-06-20 (baseline limpio)
**Veredicto:** 3/3 tareas. Comportamiento sólido y con **muy buena autocorrección**.
Mismo perfil que deepseek (explora y acierta), no aluciona el record de la página.
### Por turno
- **T1 (beneficios):** 90 tools, 250k tok. Resolvió `apartados num=267` bien. Escribió el
template Twig con `=` en un `c-if` (en vez de `==`) → fallos de `acai_write`/compilación,
pero **se autodiagnosticó** ("el compilador no convierte `=` en este contexto") y lo arregló.
- **T2 (equipo, multi-registro v2):** 77 tools, **0 err**, 232k tok. Módulo `equipococotalento_k8e2qr`.
**30 `generate_image` + 8 `add_module`** para 3 personas — idéntico a deepseek.
- **T3 (edición):** 35 tools, **0 err**, 237k tok. Sus personas eran Diego/Laura Méndez/Carmen;
infirió bien la petición. Detectó que al borrar un registro **las fotos quedaron cruzadas**
(reuso de nums 22726/22727) y las **reemplazó correctamente**.
- Totales: ~202 tools, errores solo en T1 (recuperados), ~720k tok input.
- Ventana real glm-5.2: **1.048.576**. Coste catálogo: ~$1.2 in / $4.1 out por 1M.
### Limpieza pendiente
Tras el reset, empleo (en TEST) tiene solo los módulos de la prueba de GLM:
- módulo de beneficios (`multiv2`) + `equipococotalento_k8e2qr`.
Borrar si no se quieren conservar, y **revertir empleo a producción**.

View File

@@ -7,6 +7,15 @@ from dataclasses import dataclass, field
from typing import Any, AsyncIterator
class ContextOverflowError(Exception):
"""El contexto excede la ventana del modelo (proveedor lo rechazó).
Excepción de dominio para desacoplar el orquestador de litellm: los adapters
la lanzan al detectar un error de context-length, y el loop del agente decide
si reintentar con compactación más agresiva o devolver un error accionable.
"""
@dataclass
class StreamChunk:
"""A single chunk from a streaming model response.

View File

@@ -9,10 +9,36 @@ from typing import Any, AsyncIterator
from openai import AsyncOpenAI
from ..config import settings
from .base import ModelAdapter, ModelConfig, ModelResponse, StreamChunk
from .base import (
ContextOverflowError,
ModelAdapter,
ModelConfig,
ModelResponse,
StreamChunk,
)
logger = logging.getLogger(__name__)
# Señales de que el proveedor rechazó por ventana de contexto. Detectamos por
# tipo (litellm.ContextWindowExceededError) y por mensaje (openai.BadRequestError
# u otros 400), sin acoplar el adapter a litellm con un import duro.
_CONTEXT_OVERFLOW_MARKERS = (
"context_length_exceeded",
"maximum context length",
"context window",
"context length",
"too many tokens",
"reduce the length",
"prompt is too long",
)
def _is_context_overflow(exc: Exception) -> bool:
if type(exc).__name__ in ("ContextWindowExceededError",):
return True
msg = str(getattr(exc, "message", "") or exc).lower()
return any(marker in msg for marker in _CONTEXT_OVERFLOW_MARKERS)
def _estimate_usage(messages: list[dict[str, Any]], output_text: str) -> dict[str, int]:
"""Estimacion de tokens cuando el proveedor no entrega usage (p.ej. LiteLLM
@@ -62,6 +88,26 @@ class OpenAIAdapter(ModelAdapter):
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
config: ModelConfig | None = None,
) -> AsyncIterator[StreamChunk]:
"""Envoltorio que traduce errores de ventana de contexto del proveedor a
`ContextOverflowError` (dominio), tanto si saltan al iniciar el stream
como durante la primera iteración. El loop del agente lo usa para
reintentar con compactación agresiva si aún no emitió nada."""
try:
async for chunk in self._stream_impl(messages, tools, config):
yield chunk
except ContextOverflowError:
raise
except Exception as e:
if _is_context_overflow(e):
raise ContextOverflowError(str(getattr(e, "message", "") or e)) from e
raise
async def _stream_impl(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
config: ModelConfig | None = None,
) -> AsyncIterator[StreamChunk]:
config = config or ModelConfig(
model_id=settings.default_model_id,
@@ -281,7 +327,14 @@ class OpenAIAdapter(ModelAdapter):
"function": {"name": force_tool},
}
response = await self._acreate(kwargs)
try:
response = await self._acreate(kwargs)
except ContextOverflowError:
raise
except Exception as e:
if _is_context_overflow(e):
raise ContextOverflowError(str(getattr(e, "message", "") or e)) from e
raise
choice = response.choices[0]
content = choice.message.content or ""

View File

@@ -155,5 +155,24 @@ class Settings(BaseSettings):
return min(self.compaction_threshold_tokens, self.effective_context_budget)
return max(1, int(self.effective_context_budget * self.compaction_threshold_ratio))
def budget_for_window(self, window: int, max_output: int | None = None) -> int:
"""Budget de contexto para la ventana REAL del modelo activo.
Misma fórmula que `effective_context_budget` (`window - max_output -
reserve`) pero parametrizada por la ventana del modelo del turno. Si la
ventana no es válida, cae al budget estático. Un override explícito
(`context_max_tokens`) siempre manda (lo aplica el caller)."""
if window <= 0:
return self.effective_context_budget
out = self.model_max_output_tokens if max_output is None else max_output
reserve = int(window * self.context_reserve_ratio)
return max(1, window - max(0, out) - max(0, reserve))
def compaction_threshold_for(self, budget: int) -> int:
"""Umbral de compactación para un budget dado (ratio configurable)."""
if self.compaction_threshold_tokens > 0:
return min(self.compaction_threshold_tokens, budget)
return max(1, int(budget * self.compaction_threshold_ratio))
settings = Settings()

View File

@@ -66,13 +66,35 @@ class ContextEngine:
artifacts: list[ArtifactSummary] | None = None,
conversation: list[dict[str, Any]] | None = None,
extra_instructions: str = "",
model_id: str | None = None,
budget_override: int | None = None,
) -> ContextPackage:
"""Build a full ContextPackage for the given agent and session.
The conversation parameter contains real assistant/tool messages
with complete tool results. These go into the messages array,
not the system prompt — like professional agentic tools.
El budget de contexto se deriva de la VENTANA REAL del modelo activo
(`model_id`, formato litellm) vía catálogo/litellm; `budget_override`
fuerza un budget menor (retry agresivo ante overflow).
"""
# Budget del turno: override (retry) → override duro de settings →
# ventana del modelo → fallback estático. Umbral derivado del budget.
from ..orchestrator.cost import resolve_context_window
if budget_override is not None and budget_override > 0:
budget = budget_override
elif settings.context_max_tokens > 0:
budget = settings.context_max_tokens
else:
window = await resolve_context_window(model_id) if model_id else None
budget = (
settings.budget_for_window(window)
if window
else settings.effective_context_budget
)
threshold = settings.compaction_threshold_for(budget)
sections: list[ContextSection] = []
allowed = set(agent.context_sections)
@@ -140,7 +162,7 @@ class ContextEngine:
raw_message_tokens = sum(self._estimate_message_tokens(m) for m in messages)
pre_compaction_section_tokens = sum(estimate_tokens(s.content) for s in sections)
pre_compaction_total = pre_compaction_section_tokens + raw_message_tokens
section_budget = max(1, settings.effective_context_budget - raw_message_tokens)
section_budget = max(1, budget - raw_message_tokens)
# Compact sections only when the full prompt is approaching the target.
section_compaction = {
@@ -155,8 +177,8 @@ class ContextEngine:
}
system_prompt = self._assemble_system_prompt(sections)
system_prompt_tokens = estimate_tokens(system_prompt)
hard_message_budget = max(1, settings.effective_context_budget - system_prompt_tokens)
target_message_budget = max(1, settings.effective_compaction_threshold - system_prompt_tokens)
hard_message_budget = max(1, budget - system_prompt_tokens)
target_message_budget = max(1, threshold - system_prompt_tokens)
message_budget = min(hard_message_budget, target_message_budget)
conversation_compaction = {
"budget_tokens": message_budget,
@@ -170,7 +192,7 @@ class ContextEngine:
}
total_tokens = system_prompt_tokens + raw_message_tokens
if total_tokens > settings.effective_compaction_threshold:
if total_tokens > threshold:
messages, conversation_compaction = self.compactor.compact_conversation(
messages,
max_tokens=message_budget,
@@ -181,10 +203,10 @@ class ContextEngine:
self._estimate_message_tokens(m) for m in messages
)
if total_tokens > settings.effective_context_budget:
if total_tokens > budget:
section_budget = max(
1,
settings.effective_context_budget
budget
- sum(self._estimate_message_tokens(m) for m in messages),
)
sections, section_compaction = self.compactor.compact_sections(
@@ -197,10 +219,10 @@ class ContextEngine:
self._estimate_message_tokens(m) for m in messages
)
if total_tokens > settings.effective_context_budget:
if total_tokens > budget:
hard_message_budget = max(
1,
settings.effective_context_budget - system_prompt_tokens,
budget - system_prompt_tokens,
)
messages, conversation_compaction = self.compactor.compact_conversation(
messages,
@@ -217,6 +239,7 @@ class ContextEngine:
system_prompt=system_prompt,
messages=messages,
total_token_estimate=total_tokens,
budget_tokens=budget,
)
# Guardar contexto completo del último build (solo el último por sesión)
@@ -224,8 +247,8 @@ class ContextEngine:
"system_prompt": system_prompt,
"messages": messages,
"total_tokens": total_tokens,
"budget_tokens": settings.effective_context_budget,
"threshold_tokens": settings.effective_compaction_threshold,
"budget_tokens": budget,
"threshold_tokens": threshold,
"timestamp": time.time(),
}
@@ -258,8 +281,8 @@ class ContextEngine:
"user_message_preview": user_content[:200],
"artifacts_count": len(artifacts) if artifacts else 0,
"conversation_messages": conv_len,
"budget_tokens": settings.effective_context_budget,
"threshold_tokens": settings.effective_compaction_threshold,
"budget_tokens": budget,
"threshold_tokens": threshold,
"message_tokens": conversation_compaction.get("output_tokens", raw_message_tokens),
"message_tokens_before_compaction": raw_message_tokens,
"pre_compaction_tokens": pre_compaction_total,
@@ -268,7 +291,7 @@ class ContextEngine:
"message_budget_tokens": message_budget,
"section_compaction": section_compaction,
"conversation_compaction": conversation_compaction,
"over_budget": total_tokens > settings.effective_context_budget,
"over_budget": total_tokens > budget,
}
history = self._history[session.session_id]

View File

@@ -35,6 +35,10 @@ class ContextPackage(BaseModel):
system_prompt: str = ""
messages: list[dict[str, Any]] = Field(default_factory=list)
total_token_estimate: int = 0
# Budget de contexto (tokens) usado para construir/compactar este paquete —
# derivado de la ventana del modelo activo. Lo usa el loop del agente para
# compactar más agresivo si aún no cabe en la ventana.
budget_tokens: int = 0
def to_messages(self) -> list[dict[str, Any]]:
"""Produce the final messages list for the model adapter."""

View File

@@ -9,9 +9,10 @@ import time
import uuid
from typing import Any, AsyncIterator
from ...adapters.base import ModelAdapter, ModelConfig, StreamChunk
from ...adapters.base import ContextOverflowError, ModelAdapter, ModelConfig, StreamChunk
from ...config import settings
from ...context.engine import ContextEngine
from ..cost import resolve_context_window
from ...mcp.manager import MCPManager
from ...memory.store import MemoryStore
from ...models.agent import AgentProfile
@@ -73,13 +74,41 @@ class BaseAgent:
self._current_conversation = conversation
for step in range(max_steps):
# Build context with real conversation
ctx = await self.context.build_context(
session=session,
agent=self.profile,
artifacts=artifacts,
conversation=conversation,
# Build context with real conversation. El budget se deriva de la
# ventana REAL del modelo activo; si el contexto estimado no cabe ni
# tras compactar, reconstruimos con compactación más agresiva antes
# de llamar al LLM (evita una llamada condenada a fallar). Si ni así
# cabe → ContextOverflowError → error accionable (no rompe la sesión).
model_id = self.profile.model_id or ""
model_window = (
await resolve_context_window(model_id) if model_id else None
)
ctx = None
budget_override: int | None = None
for ctx_attempt in range(3): # intento normal + 2 compactaciones agresivas
ctx = await self.context.build_context(
session=session,
agent=self.profile,
artifacts=artifacts,
conversation=conversation,
model_id=model_id,
budget_override=budget_override,
)
if not model_window or ctx.total_token_estimate <= model_window:
break
# No cabe: compactar al 60% del budget usado en el siguiente intento.
base = ctx.budget_tokens or settings.effective_context_budget
budget_override = max(2048, int(base * 0.6))
else:
raise ContextOverflowError(
"El contexto ({} tokens) supera la ventana del modelo {} ({} "
"tokens). Acorta el mensaje o cambia a un modelo con más "
"contexto.".format(
ctx.total_token_estimate if ctx else "?",
model_id or "(desconocido)",
model_window,
)
)
# Prepare tool definitions. plan_mode "off" oculta acai_plan al
# modelo (toggle del UI desactivado). "force" la expone normalmente.

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.

View File

@@ -11,7 +11,7 @@ import logging
import re
from typing import Any
from ..adapters.base import ModelAdapter
from ..adapters.base import ContextOverflowError, ModelAdapter
from ..config import settings
from ..context.engine import ContextEngine
from ..context.compactor import ContextCompactor, estimate_tokens
@@ -75,6 +75,20 @@ class OrchestratorEngine:
session_id=session.session_id,
)
return self._error_result(session, "Execution timed out")
except ContextOverflowError as e:
# El contexto no cabe en la ventana del modelo ni tras compactar al
# máximo. Mensaje accionable (no fallo genérico de plataforma): el
# usuario sabe qué hacer (acortar o cambiar de modelo).
logger.warning("Context overflow for session %s: %s", session.session_id, e)
if session.current_task:
session.current_task.mark_failed(str(e))
session.status = SessionStatus.ERROR
await self.sse.emit(
EventType.ERROR,
{"error": "context_overflow", "message": str(e)},
session_id=session.session_id,
)
return self._error_result(session, str(e))
except Exception as e:
logger.exception("Unhandled error for session %s", session.session_id)
if session.current_task:

View File

@@ -65,6 +65,128 @@ class TestSettingsBudget:
assert cfg.effective_context_budget == 172_000
assert cfg.effective_compaction_threshold == 137_600
def test_budget_for_window_small_and_large(self):
cfg = Settings(
context_max_tokens=0,
model_max_output_tokens=4_096,
context_reserve_ratio=0.10,
_env_file=None,
)
# 32k: window - max_output - 10% reserve
assert cfg.budget_for_window(32_000) == 32_000 - 4_096 - 3_200
# 1M: budget mucho mayor (no compacta innecesariamente)
assert cfg.budget_for_window(1_000_000) == 1_000_000 - 4_096 - 100_000
# ventana inválida → fallback al budget estático
assert cfg.budget_for_window(0) == cfg.effective_context_budget
def test_compaction_threshold_for_uses_ratio(self):
cfg = Settings(
compaction_threshold_tokens=0,
compaction_threshold_ratio=0.80,
_env_file=None,
)
assert cfg.compaction_threshold_for(100_000) == 80_000
class TestContextWindowResolution:
def test_resolve_window_from_catalog(self, monkeypatch):
import json
from src.orchestrator import cost
cost._window_cache.clear()
class _FakeRedis:
async def get(self, key):
return json.dumps([
{"id": "kimi-k2.7-code", "context_length": 256_000},
{"id": "otro", "context_length": 32_000},
])
monkeypatch.setattr(cost, "_get_cfg_redis", lambda: _FakeRedis())
w = asyncio.run(cost.resolve_context_window("openrouter/kimi-k2.7-code"))
assert w == 256_000
# segunda llamada usa cache (no peta aunque cambie el fake)
assert asyncio.run(cost.resolve_context_window("openrouter/kimi-k2.7-code")) == 256_000
def test_resolve_window_miss_is_none_or_int(self, monkeypatch):
from src.orchestrator import cost
cost._window_cache.clear()
class _FakeRedis:
async def get(self, key):
return None
monkeypatch.setattr(cost, "_get_cfg_redis", lambda: _FakeRedis())
w = asyncio.run(cost.resolve_context_window("openrouter/modelo-inexistente-xyz"))
assert w is None or isinstance(w, int)
def test_resolve_window_ignores_non_litellm_ids(self):
from src.orchestrator import cost
cost._window_cache.clear()
assert asyncio.run(cost.resolve_context_window("sin-prefijo")) is None
assert asyncio.run(cost.resolve_context_window(None)) is None
def test_resolve_window_self_heals_when_catalog_missing(self, monkeypatch):
"""Si el catálogo OpenRouter caducó, se repuebla en runtime (self-heal)."""
from src.orchestrator import cost
cost._window_cache.clear()
cost._or_last_refresh[0] = 0.0 # desactivar cooldown para el test
store = {}
class _FakeRedis:
async def get(self, key):
return store.get(key)
async def set(self, key, val, ex=None):
store[key] = val
monkeypatch.setattr(cost, "_get_cfg_redis", lambda: _FakeRedis())
monkeypatch.setattr(
cost, "_fetch_openrouter_catalog_sync",
lambda: [{"id": "moonshotai/kimi-x", "context_length": 262_144,
"price_in_1m": 0.6, "price_out_1m": 3.0}],
)
w = asyncio.run(cost.resolve_context_window("openrouter/moonshotai/kimi-x"))
assert w == 262_144
# quedó repoblado en el cache para futuras lecturas
assert "acai:config:ai:models_cache:openrouter" in store
class TestModelAwareBudget:
def test_build_context_uses_model_window_budget(self, monkeypatch):
from src.orchestrator import cost
async def _fake_window(model_id):
return 40_000
monkeypatch.setattr(cost, "resolve_context_window", _fake_window)
session = SessionState(immutable_rules=["No romper"])
session.begin_task("hola")
agent = AgentProfile(role="acai", name="Acai", system_prompt="Haz el trabajo.")
pkg = asyncio.run(
ContextEngine().build_context(
session=session, agent=agent, model_id="openrouter/m"
)
)
assert pkg.budget_tokens == settings.budget_for_window(40_000)
def test_budget_override_wins(self):
session = SessionState(immutable_rules=["No romper"])
session.begin_task("hola")
agent = AgentProfile(role="acai", name="Acai", system_prompt="Haz el trabajo.")
pkg = asyncio.run(
ContextEngine().build_context(
session=session, agent=agent, budget_override=12_345
)
)
assert pkg.budget_tokens == 12_345
class TestContextEngine:
def test_build_context_keeps_task_history_and_current_task(self):

View File

@@ -0,0 +1,110 @@
"""Test de integración contra sesiones REALES de Redis (db 1).
Valida el budget por-ventana y la compactación sobre las conversaciones reales
del agentic (las que los usuarios mantienen abiertas), no sobre fixtures
sintéticos. Es OPT-IN: se salta si no hay Redis disponible o no hay sesiones,
para no acoplar la suite a datos de cliente ni romper en CI.
Ejecutar contra el Redis real:
docker run --rm --network acai-net \\
-v "$PWD/agenticSystem/src:/app/src" -v "$PWD/agenticSystem/tests:/app/tests" \\
-e AGENTIC_REDIS_HOST=redis -w /app acai-vscode-plugin-agentic \\
sh -lc "pip install -q pytest pytest-asyncio; python -m pytest tests/test_context_real_session.py -q"
"""
from __future__ import annotations
import asyncio
import enum
import json
import sys
import types
import pytest
if not hasattr(enum, "StrEnum"):
class _CompatStrEnum(str, enum.Enum):
pass
enum.StrEnum = _CompatStrEnum
for _name, _attr in (("anthropic", "AsyncAnthropic"), ("openai", "AsyncOpenAI")):
if _name not in sys.modules:
_stub = types.ModuleType(_name)
setattr(_stub, _attr, type("_Stub", (), {}))
sys.modules[_name] = _stub
from src.config import settings
from src.context.compactor import estimate_tokens
from src.context.engine import ContextEngine
from src.models.agent import AgentProfile
from src.models.session import SessionState
def _load_largest_real_session():
"""Mayor sesión real de Redis db 1, o None si no hay acceso/sesiones."""
try:
import redis
r = redis.Redis(
host=settings.redis_host,
port=settings.redis_port,
db=1,
password=settings.redis_password or None,
decode_responses=True,
socket_connect_timeout=2,
)
keys = [
k for k in r.scan_iter("agentic:session:*")
if not k.endswith((":events", ":artifacts"))
]
if not keys:
return None
biggest = max(keys, key=lambda k: r.strlen(k))
raw = r.get(biggest)
return json.loads(raw) if raw else None
except Exception:
return None
def test_real_session_compacts_under_model_window(monkeypatch):
data = _load_largest_real_session()
if not data or not data.get("recent_messages"):
pytest.skip("sin Redis/sesiones reales disponibles")
rm = data["recent_messages"]
raw_tokens = sum(estimate_tokens(json.dumps(m)) for m in rm)
from src.orchestrator import cost
async def _fake_window(model_id):
return 32_000
monkeypatch.setattr(cost, "resolve_context_window", _fake_window)
session = SessionState(
immutable_rules=data.get("immutable_rules") or ["No romper"],
project_profile=data.get("project_profile") or {},
task_history=data.get("task_history") or [],
recent_messages=rm,
)
session.begin_task("Sigamos con lo anterior")
agent = AgentProfile(
role="acai",
name="Acai",
system_prompt="Haz el trabajo.",
context_sections=["immutable_rules", "task_state"],
)
pkg = asyncio.run(
ContextEngine().build_context(
session=session, agent=agent, conversation=rm, model_id="openrouter/x"
)
)
# Budget derivado de la ventana REAL del modelo (32k), no del fijo de 120k/200k.
assert pkg.budget_tokens == settings.budget_for_window(32_000)
# La sesión real se compactó de verdad (no se reenvía cruda).
assert pkg.total_token_estimate < raw_tokens
# Y el resultado cabe en el budget del modelo → no habría overflow.
assert pkg.total_token_estimate <= pkg.budget_tokens

View File

@@ -0,0 +1,93 @@
"""Tests de recuperación ante overflow de ventana de contexto.
Cubre: detección del error de context-length del proveedor, y el envoltorio del
adapter que lo traduce a `ContextOverflowError` (dominio) tanto si salta al
iniciar el stream como durante la iteración.
"""
from __future__ import annotations
import asyncio
import enum
import sys
import types
import pytest
if not hasattr(enum, "StrEnum"):
class _CompatStrEnum(str, enum.Enum):
pass
enum.StrEnum = _CompatStrEnum
if "anthropic" not in sys.modules:
anthropic_stub = types.ModuleType("anthropic")
anthropic_stub.AsyncAnthropic = type("_AsyncAnthropic", (), {})
sys.modules["anthropic"] = anthropic_stub
if "openai" not in sys.modules:
openai_stub = types.ModuleType("openai")
openai_stub.AsyncOpenAI = type("_AsyncOpenAI", (), {})
sys.modules["openai"] = openai_stub
from src.adapters.base import ContextOverflowError
from src.adapters.openai_adapter import OpenAIAdapter, _is_context_overflow
class TestOverflowDetection:
def test_detects_by_message(self):
assert _is_context_overflow(
Exception("This model's maximum context length is 8192 tokens, however you requested 9000")
)
assert _is_context_overflow(Exception("context_length_exceeded"))
assert _is_context_overflow(Exception("Please reduce the length of the messages"))
def test_does_not_flag_unrelated_errors(self):
assert not _is_context_overflow(Exception("rate limit exceeded"))
assert not _is_context_overflow(Exception("invalid api key"))
def test_detects_by_type_name(self):
class ContextWindowExceededError(Exception):
pass
assert _is_context_overflow(ContextWindowExceededError("boom"))
class TestStreamWrapperMapsOverflow:
def _make_adapter(self):
# Saltamos __init__ (no necesitamos el cliente AsyncOpenAI: parcheamos
# _stream_impl). Así el test no depende del stub de openai.
return OpenAIAdapter.__new__(OpenAIAdapter)
def test_overflow_at_stream_init_becomes_domain_error(self, monkeypatch):
adapter = self._make_adapter()
async def _impl(messages, tools=None, config=None):
raise RuntimeError("maximum context length is 32768 tokens")
yield # noqa: hace de esto un async generator
monkeypatch.setattr(adapter, "_stream_impl", _impl)
async def _run():
async for _ in adapter.stream([{"role": "user", "content": "hola"}]):
pass
with pytest.raises(ContextOverflowError):
asyncio.run(_run())
def test_non_overflow_error_propagates_unchanged(self, monkeypatch):
adapter = self._make_adapter()
async def _impl(messages, tools=None, config=None):
raise RuntimeError("connection reset by peer")
yield
monkeypatch.setattr(adapter, "_stream_impl", _impl)
async def _run():
async for _ in adapter.stream([{"role": "user", "content": "hola"}]):
pass
with pytest.raises(RuntimeError) as exc:
asyncio.run(_run())
assert not isinstance(exc.value, ContextOverflowError)