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:
@@ -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.
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user