diff --git a/evals/.gitignore b/evals/.gitignore new file mode 100644 index 0000000..1455ea2 --- /dev/null +++ b/evals/.gitignore @@ -0,0 +1,2 @@ +# Los logs de sesión contienen contenido real de proyectos de cliente. +logs/ diff --git a/evals/README.md b/evals/README.md new file mode 100644 index 0000000..dbd8d18 --- /dev/null +++ b/evals/README.md @@ -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" "" +``` + +- 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. diff --git a/evals/driver.py b/evals/driver.py new file mode 100644 index 0000000..be273f8 --- /dev/null +++ b/evals/driver.py @@ -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 _acai-net \\ + -v "$PWD/agenticSystem/evals:/data" -v "$PWD/agenticSystem/evals/logs:/logs" \\ + -w /data acai-vscode-plugin-agentic \\ + python /data/driver.py "" "" + +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"))) diff --git a/evals/results-landing-build.md b/evals/results-landing-build.md new file mode 100644 index 0000000..b492892 --- /dev/null +++ b/evals/results-landing-build.md @@ -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 ~650–720k) 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**. diff --git a/src/adapters/base.py b/src/adapters/base.py index 7062444..c153703 100644 --- a/src/adapters/base.py +++ b/src/adapters/base.py @@ -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. diff --git a/src/adapters/openai_adapter.py b/src/adapters/openai_adapter.py index 24fd7cf..06606b5 100644 --- a/src/adapters/openai_adapter.py +++ b/src/adapters/openai_adapter.py @@ -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 "" diff --git a/src/config.py b/src/config.py index 4c546ae..ade6f10 100644 --- a/src/config.py +++ b/src/config.py @@ -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() diff --git a/src/context/engine.py b/src/context/engine.py index f8d23ca..cd74d4c 100644 --- a/src/context/engine.py +++ b/src/context/engine.py @@ -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] diff --git a/src/models/context.py b/src/models/context.py index f2c6f14..ddc4557 100644 --- a/src/models/context.py +++ b/src/models/context.py @@ -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.""" diff --git a/src/orchestrator/agents/base.py b/src/orchestrator/agents/base.py index 2e557b2..44cc0b5 100644 --- a/src/orchestrator/agents/base.py +++ b/src/orchestrator/agents/base.py @@ -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. diff --git a/src/orchestrator/cost.py b/src/orchestrator/cost.py index 196ca23..e5d59e9 100644 --- a/src/orchestrator/cost.py +++ b/src/orchestrator/cost.py @@ -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 ("/"). 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 ("/").""" + 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 ("/"). + """ + 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. diff --git a/src/orchestrator/engine.py b/src/orchestrator/engine.py index 0a52ff3..ac6be03 100644 --- a/src/orchestrator/engine.py +++ b/src/orchestrator/engine.py @@ -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: diff --git a/tests/test_context_budget.py b/tests/test_context_budget.py index 415a751..58ffa45 100644 --- a/tests/test_context_budget.py +++ b/tests/test_context_budget.py @@ -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): diff --git a/tests/test_context_real_session.py b/tests/test_context_real_session.py new file mode 100644 index 0000000..94c1481 --- /dev/null +++ b/tests/test_context_real_session.py @@ -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 diff --git a/tests/test_overflow_recovery.py b/tests/test_overflow_recovery.py new file mode 100644 index 0000000..8583d76 --- /dev/null +++ b/tests/test_overflow_recovery.py @@ -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)