"""Orchestration AI : route les requêtes des clients vers LiteLLM (s01). LiteLLM (:4000) est OpenAI-compatible et route lui-même vers Qwen3 (g01) ou Claude selon l'alias `hermes-default` / `hermes-switch`. L'intégration des outils Hermes (« agir sur Funk » via le gateway :8080) est une étape ultérieure. """ from __future__ import annotations import httpx from stt_server.config import settings # Client persistant (pooling + keep-alive) : évite un handshake TCP vers LiteLLM à chaque tour. _client: httpx.AsyncClient | None = None def _get_client() -> httpx.AsyncClient: global _client if _client is None or _client.is_closed: _client = httpx.AsyncClient(timeout=settings.request_timeout) return _client async def aclose() -> None: global _client if _client is not None and not _client.is_closed: await _client.aclose() _client = None async def ask( text: str, model: str | None = None, history: list[dict] | None = None, memories: list[str] | None = None, ) -> str: system = settings.system_prompt if settings.disable_thinking: # Qwen3 est un modèle « thinking » : sans ça il dépense tout le budget max_tokens # en raisonnement (`reasoning_content`) → `content` vide, ou part en réflexion longue # → timeout (502). Le token de contrôle `/no_think` désactive le mode raisonnement. # Inoffensif pour les modèles non-Qwen (simple texte ignoré). system += "\n/no_think" if memories: souvenirs = "\n".join(f"- {m}" for m in memories) system += ( "\n\nÉléments de mémoire long-terme (peuvent aider, ignore si hors-sujet) :\n" + souvenirs ) messages = [{"role": "system", "content": system}] if history: messages += history messages.append({"role": "user", "content": text}) payload = { "model": model or settings.model, "messages": messages, "max_tokens": settings.max_tokens, "temperature": settings.temperature, } headers = {"Authorization": f"Bearer {settings.litellm_key}"} # Résilience : la chaîne LiteLLM→llama-server (ROCm) se fige par à-coups. Une 2ᵉ tentative # récupère un blip transitoire (ex. connexion keep-alive périmée) au lieu de renvoyer 502. # Timeout PAR tentative (`request_timeout`) → on ne dépasse pas ~2× ce budget au pire. last_exc: Exception | None = None for attempt in range(2): try: r = await _get_client().post( settings.litellm_url, json=payload, headers=headers, timeout=settings.request_timeout, ) r.raise_for_status() break except httpx.HTTPError as e: last_exc = e await aclose() # repart sur une connexion neuve (pool potentiellement vicié) else: raise last_exc # type: ignore[misc] msg = r.json()["choices"][0]["message"] # Filet de sécurité : si un modèle « thinking » renvoie un content vide (tout parti # en reasoning_content), on récupère le raisonnement plutôt que de renvoyer "". content = (msg.get("content") or "").strip() if not content: content = (msg.get("reasoning_content") or "").strip() return content