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:
121
src/orchestrator/cost.py
Normal file
121
src/orchestrator/cost.py
Normal 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)
|
||||
Reference in New Issue
Block a user