Runtime IA: modelo dinámico, razonamiento, coste por modelo y visión nativa

- Resolución dinámica del modelo por sesión (model_resolver): override de
  usuario (metadata) → default global (Redis db 0 acai:config:ai:*) → fallback.
  Mapea a string litellm; LiteLLMAdapter respeta el modelo por request y
  enruta openrouter/* con OPENROUTER_API_KEY del entorno.
- Razonamiento: reasoning_effort por sesión en ModelConfig/AgentProfile,
  aplicado al agente y al planner.
- Coste: cost.py calcula por modelo (catálogo OpenRouter/DeepSeek en Redis →
  litellm → fijo) y emite tarifas + modelo usado en EXECUTION_COMPLETED.
- Visión nativa: imágenes como bloques image_url en el turno del usuario
  (TaskState.image_attachments → Context Engine → adapter), con persistencia
  en recent_messages y conteo de tokens de imagen (~1500).
- El turno no se pierde al cancelar: se persiste el mensaje del usuario + marca
  de interrupción para que un "vuelve a intentarlo" tenga contexto.
- Fix analyze_image: preservar el subdirectorio de usuario del chat-upload
  (basename descartaba "<user>/" → not found).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jordan
2026-06-19 14:47:55 +01:00
parent 4543300101
commit 5883473e92
15 changed files with 408 additions and 29 deletions

121
src/orchestrator/cost.py Normal file
View File

@@ -0,0 +1,121 @@
"""Cálculo de coste por modelo (Fase 2).
Prioridad de fuentes de precio (para que el coste registrado en
`consumo_acaicode` coincida con lo que muestra el Forge Admin Panel):
1. Catálogo OpenRouter cacheado por el panel en Redis db 0
(`acai:config:ai:models_cache:openrouter` → price_in_1m / price_out_1m).
2. Price map de LiteLLM (conoce muchos modelos deepseek/, anthropic/, etc.).
3. Coste fijo de `settings` (comportamiento previo).
"""
from __future__ import annotations
import json
import logging
import redis.asyncio as redis
from ..config import settings
logger = logging.getLogger(__name__)
# Caches de catálogo que publica el Forge Admin Panel en Redis db 0, por proveedor.
# El id se guarda SIN el prefijo de proveedor de litellm (p.ej.
# "moonshotai/kimi-k2.7-code", "deepseek-v4-pro").
_CACHE_KEYS = {
"openrouter": "acai:config:ai:models_cache:openrouter",
"deepseek": "acai:config:ai:models_cache:deepseek",
}
_CONFIG_DB = 0
_cfg_redis: "redis.Redis | None" = None
def _get_cfg_redis() -> "redis.Redis":
global _cfg_redis
if _cfg_redis is None:
_cfg_redis = redis.Redis(
host=settings.redis_host,
port=settings.redis_port,
db=_CONFIG_DB,
password=settings.redis_password or None,
decode_responses=True,
)
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.
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)
if not cache_key:
return None
try:
cached = await _get_cfg_redis().get(cache_key)
if not cached:
return None
models = json.loads(cached)
except Exception as e: # pragma: no cover - defensivo
logger.warning("catálogo %s no disponible para coste: %s", provider, e)
return None
for m in models:
if m.get("id") == raw_id:
pin = m.get("price_in_1m")
pout = m.get("price_out_1m")
if pin is not None and pout is not None:
return (float(pin), float(pout))
return None
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.
Devuelve {"cost_usd", "input_cost_1m", "output_cost_1m"} — el coste total y
las tarifas por 1M tokens REALMENTE aplicadas (se almacenan en
`consumo_acaicode.input_cost_1M` / `output_cost_1M`).
"""
input_tokens = int(input_tokens or 0)
output_tokens = int(output_tokens or 0)
def _result(in_1m: float, out_1m: float) -> dict:
return {
"cost_usd": (input_tokens / 1_000_000) * in_1m + (output_tokens / 1_000_000) * out_1m,
"input_cost_1m": round(in_1m, 6),
"output_cost_1m": round(out_1m, 6),
}
# 1. Precio del catálogo OpenRouter (fuente que muestra el admin).
prices = await _catalog_price_per_1m(model_id)
if prices:
return _result(prices[0], prices[1])
# 2. Price map de LiteLLM (deepseek/, anthropic/, etc.).
if model_id and "/" in model_id:
try:
import litellm
prompt_cost, completion_cost = litellm.cost_per_token(
model=model_id,
prompt_tokens=input_tokens,
completion_tokens=output_tokens,
)
total = (prompt_cost or 0.0) + (completion_cost or 0.0)
if total > 0:
# Derivar tarifa por 1M a partir del coste por-token de litellm.
in_1m = (prompt_cost / input_tokens) * 1_000_000 if input_tokens else 0.0
out_1m = (completion_cost / output_tokens) * 1_000_000 if output_tokens else 0.0
return {
"cost_usd": total,
"input_cost_1m": round(in_1m, 6),
"output_cost_1m": round(out_1m, 6),
}
except Exception as e:
logger.warning("cost_per_token(%s) falló, uso coste fijo: %s", model_id, e)
# 3. Coste fijo configurado.
return _result(settings.cost_per_1m_input, settings.cost_per_1m_output)