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

View File

@@ -77,6 +77,8 @@ class OpenAIAdapter(ModelAdapter):
"stream": True,
"stream_options": {"include_usage": True},
}
if getattr(config, "reasoning_effort", ""):
kwargs["reasoning_effort"] = config.reasoning_effort
if tools:
kwargs["tools"] = self._format_tools(tools)
@@ -266,6 +268,8 @@ class OpenAIAdapter(ModelAdapter):
"temperature": config.temperature,
"messages": self._to_openai_messages(messages),
}
if getattr(config, "reasoning_effort", ""):
kwargs["reasoning_effort"] = config.reasoning_effort
if tools:
kwargs["tools"] = self._format_tools(tools)
# Fuerza al modelo a usar un tool concreto para garantizar JSON por schema
@@ -428,8 +432,9 @@ class OpenAIAdapter(ModelAdapter):
if tool_calls:
m["tool_calls"] = tool_calls
out.append(m)
else: # user (puede traer tool_result blocks)
else: # user (puede traer tool_result blocks, texto e imágenes)
text_parts = []
image_blocks: list[dict[str, Any]] = []
for b in content:
if not isinstance(b, dict):
continue
@@ -442,7 +447,18 @@ class OpenAIAdapter(ModelAdapter):
})
elif t == "text":
text_parts.append(b.get("text", ""))
if text_parts:
elif t == "image_url":
# Visión nativa: preservar el bloque en formato multimodal OpenAI.
image_blocks.append({"type": "image_url", "image_url": b.get("image_url") or {}})
if image_blocks:
# Content como lista de bloques (texto + imágenes).
parts: list[dict[str, Any]] = []
joined = "\n".join(p for p in text_parts if p)
if joined:
parts.append({"type": "text", "text": joined})
parts.extend(image_blocks)
out.append({"role": "user", "content": parts})
elif text_parts:
out.append({"role": "user", "content": "\n".join(text_parts)})
# Guard defensivo: el compactor ya garantiza el invariante tool_use ↔
# tool_result (`_enforce_tool_pairing`), pero si algo se escapa el