"""Backend LLM mlx-lm (Apple Silicon) — moteur historique d'InkFlow. Charge un modele mlx-community paresseusement (une fois par process, cache LRU) et genere via le template de chat du tokenizer. Comportement strictement identique a l'ancien `Gemma.generate` : controle fin de `enable_thinking`, streaming avec arret anticipe des que le JSON post-pensee est complet. """ from __future__ import annotations import json from functools import lru_cache from typing import Callable, Optional from .base import LLMBackend from ._text import _REASONING_END_MARKERS, _has_complete_json, _strip_reasoning @lru_cache(maxsize=2) def _load(model_id: str): # Import paresseux : evite de charger mlx tant qu'on n'analyse pas. from mlx_lm import load return load(model_id) def _resolve_chat_template(model_id: str, tokenizer) -> Optional[str]: """Renvoie un template de chat a passer explicitement, ou None. Certaines conversions (Mistral recents...) logent leur template dans un fichier `chat_template.jinja` que le downloader de mlx-lm n'embarque pas toujours : `tokenizer.chat_template` est alors vide et `apply_chat_template` echoue. On recupere alors le fichier officiel du repo. None si le tokenizer possede deja un template (cas courant) ou si aucun n'est disponible. """ if getattr(tokenizer, "chat_template", None): return None from pathlib import Path from huggingface_hub import hf_hub_download # Selon les conversions : fichier Jinja brut, ou JSON {"chat_template": ...}. for fname in ("chat_template.jinja", "chat_template.json"): try: text = Path(hf_hub_download(model_id, fname)).read_text(encoding="utf-8") except Exception: # noqa: BLE001 — fichier absent, on tente le suivant continue if fname.endswith(".json"): data = json.loads(text) return data.get("chat_template") if isinstance(data, dict) else None return text return None # aucun template dispo -> apply_chat_template levera une erreur claire class MLXBackend(LLMBackend): """Moteur mlx-lm : modeles mlx-community (HuggingFace) sur Apple Silicon.""" name = "mlx" def __init__(self, model_ref: str): super().__init__(model_ref) self._model = None self._tokenizer = None self._chat_template = None # template recupere si absent du tokenizer def _ensure_loaded(self) -> None: if self._model is None: self._model, self._tokenizer = _load(self.model_ref) self._chat_template = _resolve_chat_template( self.model_ref, self._tokenizer) def complete( self, messages: list[dict], *, max_tokens: int, temperature: float, reasoning: bool, token_sink: Optional[Callable[[str], None]] = None, ) -> str: self._ensure_loaded() from mlx_lm.sample_utils import make_sampler # Modeles hybrides (Qwen3...) : hors mode raisonnement, on DESACTIVE la # pensee via enable_thinking=False -> JSON direct, bien plus rapide. Avec # raisonnement, on laisse penser puis la facade retire la pensee. Ce # kwarg est ignore par les templates qui ne l'utilisent pas (Gemma...). template_kwargs = {} if not reasoning: template_kwargs["enable_thinking"] = False formatted = self._tokenizer.apply_chat_template( messages, add_generation_prompt=True, tokenize=False, chat_template=self._chat_template, # None -> celui du tokenizer **template_kwargs, ) sampler = make_sampler(temp=temperature) # On streame (token par token) si : un sink est branche (--stream) OU on # est en mode raisonnement (pour pouvoir s'arreter des que la reponse est # prete, sans subir les boucles de pensee sans fin). Sinon, lot rapide. if token_sink is not None or reasoning: from mlx_lm import stream_generate parts = [] seen_end = False # marqueur de fin de pensee rencontre for resp in stream_generate( self._model, self._tokenizer, prompt=formatted, max_tokens=max_tokens, sampler=sampler, ): parts.append(resp.text) if token_sink is not None: token_sink(resp.text) # Arret anticipe : une fois la pensee close, des que le JSON # post-pensee est complet, inutile de continuer a generer. if reasoning and ("}" in resp.text or "]" in resp.text): buf = "".join(parts) if not seen_end: seen_end = any(mk in buf for mk in _REASONING_END_MARKERS) if seen_end and _has_complete_json(_strip_reasoning(buf)): break if token_sink is not None: token_sink("\n") # separe les generations successives return "".join(parts) from mlx_lm import generate return generate( self._model, self._tokenizer, prompt=formatted, max_tokens=max_tokens, sampler=sampler, verbose=False, )