Spaces:
Configuration error
Configuration error
| # core/multi_backend_engine.py | |
| import os | |
| import logging | |
| import asyncio | |
| import aiohttp | |
| import hashlib | |
| import time | |
| import traceback | |
| from core.semantic_router import SemanticRouter | |
| log = logging.getLogger("vortex.multibackend") | |
| class LLMResponse: | |
| __slots__ = ("content", "tokens", "cached") | |
| def __init__(self, content: str, tokens: int = 0, cached: bool = False): | |
| self.content = content | |
| self.tokens = tokens | |
| self.cached = cached | |
| class BaseBackend: | |
| def __init__(self): | |
| self.failure_count = 0 | |
| self.last_failure_time = 0 | |
| self.quarantine_duration = 60 | |
| self.quarantined = False | |
| def record_failure(self): | |
| self.failure_count += 1 | |
| self.last_failure_time = time.time() | |
| if self.failure_count >= 3: | |
| self.quarantined = True | |
| log.warning(f"[{self.__class__.__name__}] Mis en quarantaine après 3 échecs.") | |
| else: | |
| log.warning(f"[{self.__class__.__name__}] Échec #{self.failure_count}") | |
| def record_success(self): | |
| self.failure_count = 0 | |
| self.quarantined = False | |
| self.last_failure_time = 0 | |
| async def is_available(self) -> bool: | |
| return False | |
| async def generate(self, model: str, system: str, user: str, max_tokens: int, temperature: float) -> str: | |
| raise NotImplementedError | |
| class OllamaBackend(BaseBackend): | |
| def __init__(self, base_url="http://localhost"): | |
| super().__init__() | |
| self.base_url = base_url | |
| self.ports = [11434, 11435, 11436] | |
| self._active_port = None | |
| self._session = None | |
| async def _get_session(self): | |
| if self._session is None or self._session.closed: | |
| self._session = aiohttp.ClientSession() | |
| return self._session | |
| async def is_available(self) -> bool: | |
| if self.quarantined: | |
| if time.time() - self.last_failure_time > self.quarantine_duration: | |
| self.quarantined = False | |
| self.failure_count = 0 | |
| log.info(f"[OllamaBackend] Sortie de quarantaine.") | |
| else: | |
| return False | |
| session = await self._get_session() | |
| for port in self.ports: | |
| try: | |
| url = f"{self.base_url}:{port}/api/tags" | |
| async with session.get(url, timeout=2) as resp: | |
| if resp.status == 200: | |
| self._active_port = port | |
| return True | |
| except Exception: | |
| continue | |
| return False | |
| async def generate(self, model: str, system: str, user: str, max_tokens: int, temperature: float) -> str: | |
| if not self._active_port and not await self.is_available(): | |
| self.record_failure() | |
| return "[Ollama] Service indisponible." | |
| session = await self._get_session() | |
| url = f"{self.base_url}:{self._active_port}/api/chat" | |
| payload = { | |
| "model": model, | |
| "messages": [ | |
| {"role": "system", "content": system or "Tu es VORTEX."}, | |
| {"role": "user", "content": user} | |
| ], | |
| "options": {"num_predict": max_tokens, "temperature": temperature}, | |
| "stream": False | |
| } | |
| try: | |
| async with session.post(url, json=payload, timeout=180) as resp: | |
| if resp.status == 200: | |
| data = await resp.json() | |
| content = data.get("message", {}).get("content", "") | |
| if content: | |
| self.record_success() | |
| return content | |
| else: | |
| self.record_failure() | |
| return "[Ollama] Réponse vide." | |
| self.record_failure() | |
| return f"[Ollama] HTTP {resp.status}" | |
| except Exception as e: | |
| self.record_failure() | |
| return f"[Ollama] Erreur: {e}" | |
| class HFInferenceBackend(BaseBackend): | |
| def __init__(self): | |
| super().__init__() | |
| self.token = os.environ.get("HF_TOKEN") | |
| self._available = bool(self.token) | |
| # Mise à jour du modèle pour l'API HF | |
| self.default_model = "Qwen/Qwen2.5-Coder-7B-Instruct" | |
| async def is_available(self) -> bool: | |
| if self.quarantined: | |
| if time.time() - self.last_failure_time > self.quarantine_duration: | |
| self.quarantined = False | |
| self.failure_count = 0 | |
| log.info(f"[HFInferenceBackend] Sortie de quarantaine.") | |
| else: | |
| return False | |
| return self._available | |
| async def generate(self, model: str, system: str, user: str, max_tokens: int, temperature: float) -> str: | |
| if not self.token: | |
| self.record_failure() | |
| return "[HF] Token non configuré." | |
| hf_model = self.default_model | |
| try: | |
| from huggingface_hub import InferenceClient | |
| client = InferenceClient(token=self.token) | |
| # Utilisation de chat_completion qui est plus robuste | |
| messages = [] | |
| if system and system.strip(): | |
| messages.append({"role": "system", "content": system}) | |
| messages.append({"role": "user", "content": user}) | |
| resp = client.chat_completion( | |
| model=hf_model, | |
| messages=messages, | |
| max_tokens=max_tokens, | |
| temperature=max(temperature, 0.01) | |
| ) | |
| if resp and resp.choices and len(resp.choices) > 0: | |
| content = resp.choices[0].message.content | |
| if content and content.strip(): | |
| self.record_success() | |
| return content.strip() | |
| # Fallback sur text_generation si chat_completion échoue | |
| try: | |
| resp2 = client.text_generation( | |
| user, | |
| model=hf_model, | |
| max_new_tokens=max_tokens, | |
| temperature=max(temperature, 0.01), | |
| do_sample=True | |
| ) | |
| if resp2 and resp2.strip(): | |
| self.record_success() | |
| return resp2.strip() | |
| except Exception: | |
| pass | |
| self.record_failure() | |
| return "[HF] Réponse vide." | |
| except Exception as e: | |
| self.record_failure() | |
| return f"[HF] Erreur: {e}" | |
| class LocalTransformersBackend(BaseBackend): | |
| def __init__(self, error_tree=None): | |
| super().__init__() | |
| self._model = None | |
| self._tokenizer = None | |
| self._loaded = False | |
| self._loading = False | |
| self._torch_available = False | |
| self._transformers_available = False | |
| # 🔥 NOUVEAU MODÈLE : Qwen2.5-Coder-7B-Instruct | |
| self.max_tokens_cap = int(os.environ.get("LOCAL_MAX_TOKENS_CAP", "512")) | |
| self.timeout_s = float(os.environ.get("LOCAL_TIMEOUT_S", "45.0")) | |
| self.model_name = "Qwen/Qwen2.5-Coder-7B-Instruct" | |
| self.error_tree = error_tree | |
| try: | |
| import torch | |
| self._torch_available = True | |
| except ImportError: | |
| log.warning("[Local] torch non installé.") | |
| try: | |
| import transformers | |
| self._transformers_available = True | |
| except ImportError: | |
| log.warning("[Local] transformers non installé.") | |
| def _build_prompt(self, system: str, user: str) -> str: | |
| if self._tokenizer is None: | |
| return user | |
| messages = [] | |
| if system and system.strip(): | |
| messages.append({"role": "system", "content": system}) | |
| messages.append({"role": "user", "content": user}) | |
| try: | |
| return self._tokenizer.apply_chat_template( | |
| messages, | |
| tokenize=False, | |
| add_generation_prompt=True | |
| ) | |
| except Exception as e: | |
| log.warning(f"[Local] apply_chat_template échoué: {e}, fallback user prompt") | |
| return user | |
| async def is_available(self) -> bool: | |
| if not self._torch_available or not self._transformers_available: | |
| return False | |
| if self.quarantined: | |
| if time.time() - self.last_failure_time > self.quarantine_duration: | |
| self.quarantined = False | |
| self.failure_count = 0 | |
| log.info(f"[LocalTransformersBackend] Sortie de quarantaine.") | |
| else: | |
| return False | |
| if self._loaded: | |
| return True | |
| if self._loading: | |
| return False | |
| self._loading = True | |
| try: | |
| import torch | |
| from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig | |
| log.info(f"[Local] Chargement de {self.model_name} en 4-bit NF4...") | |
| bnb_config = BitsAndBytesConfig( | |
| load_in_4bit=True, | |
| bnb_4bit_use_double_quant=True, | |
| bnb_4bit_quant_type="nf4", | |
| bnb_4bit_compute_dtype=torch.float16, | |
| ) | |
| self._tokenizer = AutoTokenizer.from_pretrained( | |
| self.model_name, | |
| trust_remote_code=True, | |
| clean_up_tokenization_spaces=False | |
| ) | |
| self._model = AutoModelForCausalLM.from_pretrained( | |
| self.model_name, | |
| quantization_config=bnb_config, | |
| device_map="auto", | |
| torch_dtype=torch.float16, | |
| trust_remote_code=True | |
| ) | |
| self._loaded = True | |
| self._loading = False | |
| log.info(f"[Local] {self.model_name} chargé avec succès.") | |
| return True | |
| except Exception as e: | |
| self._loading = False | |
| log.error(f"[Local] Échec du chargement: {e}") | |
| log.error(traceback.format_exc()) | |
| if self.error_tree is not None: | |
| try: | |
| self.error_tree.add_error(str(e), f"LocalTransformers chargement") | |
| except Exception: | |
| pass | |
| self.record_failure() | |
| return False | |
| async def generate(self, model: str, system: str, user: str, max_tokens: int, temperature: float) -> str: | |
| if not self._loaded and not await self.is_available(): | |
| self.record_failure() | |
| return "[Local] Modèle non chargé." | |
| effective_max_tokens = min(max_tokens, self.max_tokens_cap) | |
| prompt = self._build_prompt(system, user) | |
| def _generate_sync(): | |
| import torch | |
| t0 = time.perf_counter() | |
| inputs = self._tokenizer(prompt, return_tensors="pt").to(self._model.device) | |
| with torch.no_grad(): | |
| outputs = self._model.generate( | |
| **inputs, | |
| max_new_tokens=effective_max_tokens, | |
| temperature=max(temperature, 0.01) if temperature > 0 else None, | |
| do_sample=temperature > 0.0 and len(prompt) < 500, | |
| pad_token_id=self._tokenizer.eos_token_id, | |
| num_beams=1, | |
| ) | |
| result = self._tokenizer.decode( | |
| outputs[0][inputs.input_ids.shape[1]:], | |
| skip_special_tokens=True | |
| ).strip() | |
| t1 = time.perf_counter() | |
| log.info(f"[Local] Génération en {t1-t0:.2f}s ({effective_max_tokens} max tokens)") | |
| return result | |
| try: | |
| result = await asyncio.wait_for( | |
| asyncio.to_thread(_generate_sync), | |
| timeout=self.timeout_s | |
| ) | |
| if result: | |
| self.record_success() | |
| return result | |
| else: | |
| self.record_failure() | |
| if self.error_tree is not None: | |
| try: | |
| self.error_tree.add_error( | |
| "Réponse vide du backend local", | |
| f"Prompt: {prompt[:100]}" | |
| ) | |
| except Exception: | |
| pass | |
| return "[Local] Réponse vide." | |
| except asyncio.TimeoutError: | |
| self.record_failure() | |
| log.error(f"[Local] Génération trop lente (>{self.timeout_s}s).") | |
| if self.error_tree is not None: | |
| try: | |
| self.error_tree.add_error( | |
| f"Timeout local ({self.timeout_s}s)", | |
| f"Prompt: {prompt[:100]}" | |
| ) | |
| except Exception: | |
| pass | |
| return f"[Local] Timeout après {self.timeout_s}s." | |
| except Exception as e: | |
| self.record_failure() | |
| log.error(f"[Local] Erreur génération: {type(e).__name__}: {e}") | |
| log.error(traceback.format_exc()) | |
| if self.error_tree is not None: | |
| try: | |
| self.error_tree.add_error(str(e), f"LocalTransformers génération") | |
| except Exception: | |
| pass | |
| return f"[Local] Erreur: {e}" | |
| class MultiBackendLLMEngine: | |
| def __init__(self, memory=None, error_tree=None): | |
| self.memory = memory | |
| self.error_tree = error_tree | |
| self.router = SemanticRouter() | |
| self._cache = {} | |
| self.stats = {"total": 0, "backends": {}} | |
| self.phi_model = os.environ.get("PHI_MODEL", "phi-4:latest") | |
| self.gemma_model = os.environ.get("GEMMA_MODEL", "gemma4-moe:latest") | |
| # Ordre des backends : LOCAL en PRIORITÉ, puis HF, puis Ollama | |
| self.backends = [] | |
| try: | |
| import torch | |
| self.backends.append(LocalTransformersBackend(error_tree=self.error_tree)) | |
| log.info("[Multi] Backend local activé (Qwen2.5-Coder-7B-Instruct).") | |
| except ImportError: | |
| log.warning("[Multi] torch non trouvé, backend local désactivé.") | |
| self.backends.append(HFInferenceBackend()) | |
| self.backends.append(OllamaBackend()) | |
| self._active_backend = None | |
| self._backend_checked = False | |
| async def _discover_backend(self): | |
| if self._backend_checked and self._active_backend is not None: | |
| still_ok = await self._active_backend.is_available() | |
| if still_ok: | |
| return self._active_backend | |
| self._active_backend = None | |
| self._backend_checked = False | |
| for backend in self.backends: | |
| ok = await backend.is_available() | |
| if ok: | |
| self._active_backend = backend | |
| self._backend_checked = True | |
| log.info(f"[Multi] Backend actif : {backend.__class__.__name__}") | |
| return backend | |
| if self.backends: | |
| self._active_backend = self.backends[-1] | |
| self._backend_checked = True | |
| log.warning("[Multi] Aucun backend disponible, fallback sur le dernier backend.") | |
| return self._active_backend | |
| else: | |
| log.error("[Multi] Aucun backend configuré.") | |
| return None | |
| async def call(self, agent=None, system="", user="", max_tokens=512, temperature=0.3, | |
| use_cache=True, fast=False, profile=None, **kwargs) -> LLMResponse: | |
| try: | |
| action = self.router.route(user) | |
| use_phi = action in ("reason", "act") or any(kw in user.lower() for kw in [ | |
| "code", "def ", "class ", "python", "function", "algorithm", | |
| "optimize", "calculate", "math", "pytest", "assert", "import" | |
| ]) | |
| if fast: | |
| use_phi = True | |
| model = self.phi_model if use_phi else self.gemma_model | |
| self.stats["total"] += 1 | |
| log.info(f"[Multi] Routing to {'Phi' if use_phi else 'Gemma'} (model: {model})") | |
| key = hashlib.sha256(f"{model}|{system}|{user}|{max_tokens}|{temperature}".encode()).hexdigest()[:20] | |
| if use_cache and key in self._cache: | |
| return LLMResponse(self._cache[key], tokens=0, cached=True) | |
| backend = await self._discover_backend() | |
| if backend is None: | |
| raise RuntimeError("Aucun backend valide disponible") | |
| backend_name = backend.__class__.__name__ | |
| self.stats["backends"][backend_name] = self.stats["backends"].get(backend_name, 0) + 1 | |
| content = await backend.generate(model, system, user, max_tokens, temperature) | |
| if isinstance(content, str) and content.startswith("[") and \ | |
| ("Erreur" in content or "indisponible" in content or "non configuré" in content or "non chargé" in content): | |
| self._active_backend = None | |
| self._backend_checked = False | |
| log.warning(f"[Multi] Backend {backend_name} a échoué. Redécouverte au prochain appel.") | |
| else: | |
| backend.record_success() | |
| if use_cache and isinstance(content, str) and not content.startswith("["): | |
| self._cache[key] = content | |
| return LLMResponse(content, tokens=len(content.split()) if isinstance(content, str) else 0, cached=False) | |
| except Exception as e: | |
| log.error(f"[Multi] Exception in call: {e}") | |
| log.error(traceback.format_exc()) | |
| if self.error_tree is not None: | |
| try: | |
| self.error_tree.add_error(str(e), f"MultiBackendLLMEngine call") | |
| except Exception: | |
| pass | |
| return LLMResponse(f"[Erreur interne] {e}", tokens=0, cached=False) | |
| def get_stats(self): | |
| return self.stats | |
| # Alias pour rétrocompatibilité | |
| DualLLMEngine = MultiBackendLLMEngine |