"""Model loading and ClichePenaltyProcessor.""" import re import time import torch import gc from threading import Thread from typing import Iterator import httpx import httpcore from transformers import ( AutoModelForCausalLM, AutoTokenizer, LogitsProcessor, LogitsProcessorList, TextIteratorStreamer, ) from loguru import logger from witgym import config _model = None _tokenizer = None _inference_clients: dict[str, object] = {} def _is_transport_error(exc: BaseException) -> bool: if isinstance(exc, (httpx.RemoteProtocolError, httpx.ConnectError, httpcore.RemoteProtocolError)): return True cause = getattr(exc, "__cause__", None) return cause is not None and _is_transport_error(cause) def _is_extra_body_rejection(exc: BaseException) -> bool: if isinstance(exc, httpx.HTTPStatusError): return 400 <= exc.response.status_code < 500 msg = str(exc).lower() return "extra_body" in msg or "chat_template_kwargs" in msg or "enable_thinking" in msg def _reset_inference_client(provider: str) -> None: _inference_clients.pop(provider, None) def _get_inference_client(provider: str): if provider not in _inference_clients: from huggingface_hub import InferenceClient _inference_clients[provider] = InferenceClient( model=config.LLM_MODEL_ID, token=config.HF_TOKEN or None, provider=provider, timeout=config.HF_API_TIMEOUT, ) return _inference_clients[provider] def load_model(): """Load local weights + tokenizer, or (None, None) when LLM_BACKEND=hf_api.""" global _model, _tokenizer if config.LLM_BACKEND == "hf_api": providers = ",".join(config.HF_INFERENCE_PROVIDERS) logger.info( f"HF API backend ({providers}) — " f"remote inference for {config.LLM_MODEL_ID}, no local weights or tokenizer" ) return None, None if _model is not None and _tokenizer is not None: return _model, _tokenizer logger.info(f"Loading tokenizer for {config.LLM_MODEL_ID}") _tokenizer = AutoTokenizer.from_pretrained( config.LLM_MODEL_ID, token=config.HF_TOKEN, trust_remote_code=True, ) logger.info(f"Loading model {config.LLM_MODEL_ID} on {config.DEVICE} ({config.DTYPE})") _model = AutoModelForCausalLM.from_pretrained( config.LLM_MODEL_ID, dtype=config.DTYPE, device_map=None, # Never device_map="auto" on MPS token=config.HF_TOKEN, trust_remote_code=True, ) _model = _model.to(config.DEVICE) _model.eval() logger.info("Model loaded.") return _model, _tokenizer def _apply_chat_template_no_think(tokenizer, messages: list) -> str: """Apply chat template with thinking disabled. Tries three strategies in order: 1. enable_thinking=False as direct kwarg (Qwen3 transformers >=4.51) 2. chat_template_kwargs={"enable_thinking": False} 3. Prepend /no_think to message (always supported by Qwen3 chat template) """ # Strategy 1: direct kwarg try: return tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True, enable_thinking=False, ) except TypeError: pass # Strategy 2: chat_template_kwargs dict try: return tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True, chat_template_kwargs={"enable_thinking": False}, ) except (TypeError, Exception): pass # Strategy 3: /no_think prefix in the last user message (always works) patched = list(messages) patched[-1] = dict(patched[-1]) patched[-1]["content"] = "/no_think\n" + patched[-1]["content"] return tokenizer.apply_chat_template( patched, tokenize=False, add_generation_prompt=True, ) def _strip_thinking(text: str) -> str: """Strip thinking blocks from model output (defence-in-depth).""" text = re.sub(r".*?", "", text, flags=re.DOTALL) text = re.sub(r"Thinking Process:.*?(?=\{|\Z)", "", text, flags=re.DOTALL) text = re.sub( r"Here'?s a thinking process:.*?(?=\n\n|\Z)", "", text, flags=re.DOTALL | re.IGNORECASE, ) if " \n\n" in text: text = text.split(" \n\n")[-1] return text.strip() def _hf_api_user_content(prompt: str, config_type: str = "generate") -> str: """Disable Qwen thinking mode when provider rejects chat_template_kwargs.""" prefix = "" if config_type in ("extract", "rank"): prefix = "You are a JSON extractor. No reasoning. Output JSON only.\n" if "Qwen3.5" in config.LLM_MODEL_ID or "Qwen3.6" in config.LLM_MODEL_ID: stripped = prompt.lstrip() if not stripped.startswith("/no_think"): prompt = f"/no_think\n{prompt}" return prefix + prompt def _extract_hf_message_text(message) -> str: """Read assistant text from HF chat response.""" content = _strip_thinking((message.content or "").strip()) if content and not content.lower().startswith("here's a thinking"): return content reasoning = _strip_thinking((getattr(message, "reasoning", None) or "").strip()) if reasoning: return reasoning return content def _hf_api_extra_body() -> dict: if "Qwen3.5" in config.LLM_MODEL_ID or "Qwen3.6" in config.LLM_MODEL_ID: return {"chat_template_kwargs": {"enable_thinking": False}} return {} def _hf_api_messages(prompt: str, config_type: str) -> list: return [{"role": "user", "content": _hf_api_user_content(prompt, config_type)}] def _hf_chat_completion(messages: list, config_type: str, *, stream: bool = False): """Try provider chain with transport retries and separate extra_body handling.""" kwargs = _generation_kwargs(config_type) extra = _hf_api_extra_body() last_exc: BaseException | None = None for provider in config.HF_INFERENCE_PROVIDERS: for attempt in range(config.HF_API_MAX_RETRIES): client = _get_inference_client(provider) use_extra = bool(extra) try: if stream: return client.chat_completion( messages, stream=True, **kwargs, **({"extra_body": extra} if use_extra else {}) ) return client.chat_completion( messages, **kwargs, **({"extra_body": extra} if use_extra else {}) ) except Exception as e: last_exc = e if use_extra and _is_extra_body_rejection(e) and not _is_transport_error(e): logger.warning( f"HF API extra_body rejected by {provider} ({e}); retrying without thinking toggle" ) try: if stream: return client.chat_completion(messages, stream=True, **kwargs) return client.chat_completion(messages, **kwargs) except Exception as e2: last_exc = e2 e = e2 if _is_transport_error(e): _reset_inference_client(provider) backoff = 0.5 * (2 ** attempt) logger.warning( f"HF API transport error ({provider}, attempt {attempt + 1}): {e}; " f"retry in {backoff:.1f}s" ) time.sleep(backoff) continue logger.warning(f"HF API error ({provider}): {e}; trying next provider") break if last_exc is not None: raise last_exc raise RuntimeError("HF API chat_completion failed with no providers configured") def is_hf_transport_error(exc: BaseException) -> bool: """Public helper for graceful degradation at pipeline boundaries.""" return _is_transport_error(exc) def _generate_via_hf_api(prompt: str, config_type: str) -> str: """Route generation through Hugging Face Inference Providers.""" messages = _hf_api_messages(prompt, config_type) output = _hf_chat_completion(messages, config_type, stream=False) raw = _extract_hf_message_text(output.choices[0].message) if not raw: logger.warning(f"HF API returned empty text (config_type={config_type})") return raw def _generation_kwargs(config_type: str) -> dict: if config_type == "extract": return {"max_tokens": config.EXTRACT_MAX_NEW_TOKENS, "temperature": 0.2} if config_type == "generate": return { "max_tokens": config.GENERATE_MAX_NEW_TOKENS, "temperature": config.GENERATE_TEMP, "top_p": 0.95, } if config_type == "rank": return {"max_tokens": 128, "temperature": 0.0} raise ValueError(f"Unknown config_type: {config_type}") def _local_generation_kwargs(config_type: str) -> dict: if config_type == "extract": return dict(do_sample=False, max_new_tokens=config.EXTRACT_MAX_NEW_TOKENS) if config_type == "generate": return dict( temperature=config.GENERATE_TEMP, do_sample=True, min_p=config.GENERATE_MIN_P, max_new_tokens=config.GENERATE_MAX_NEW_TOKENS, ) if config_type == "rank": return dict(do_sample=False, max_new_tokens=10) raise ValueError(f"Unknown config_type: {config_type}") def _stream_hf_api_tokens(prompt: str, config_type: str) -> Iterator[str]: messages = _hf_api_messages(prompt, config_type) stream = _hf_chat_completion(messages, config_type, stream=True) for chunk in stream: if not chunk.choices: continue delta = chunk.choices[0].delta content = getattr(delta, "content", None) if delta else None if content: yield content def generate_text_stream( prompt: str, model, tokenizer, config_type: str = "generate", logits_processors: LogitsProcessorList = None, ) -> Iterator[str]: """Token stream for generate/compress paths. Extract/rank stay non-streaming.""" if config_type not in ("generate", "extract"): raise ValueError(f"Streaming not supported for config_type={config_type}") if config.LLM_BACKEND == "hf_api": yield from _stream_hf_api_tokens(prompt, config_type) return messages = [{"role": "user", "content": prompt}] text = _apply_chat_template_no_think(tokenizer, messages) inputs = tokenizer(text, return_tensors="pt").to(config.DEVICE) gen_kwargs = _local_generation_kwargs(config_type) streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True) def _run_generate(): with torch.no_grad(): model.generate( **inputs, streamer=streamer, logits_processor=logits_processors or LogitsProcessorList(), pad_token_id=tokenizer.eos_token_id, **gen_kwargs, ) thread = Thread(target=_run_generate, daemon=True) thread.start() for token in streamer: if token: yield token thread.join() if config.DEVICE == "mps": torch.mps.empty_cache() gc.collect() class ClichePenaltyProcessor(LogitsProcessor): """Soft penalty on the opening tokens of the obvious/boring response. We penalise (not hard-suppress) so the model is steered away without losing coherence. """ def __init__(self, obvious_response: str, tokenizer): ids = tokenizer.encode(obvious_response, add_special_tokens=False) self.penalty_ids = set(ids[: config.CLICHE_PENALTY_TOKENS]) self.penalty = config.CLICHE_LOGIT_PENALTY def __call__(self, input_ids: torch.LongTensor, scores: torch.FloatTensor) -> torch.FloatTensor: for token_id in self.penalty_ids: if token_id < scores.shape[-1]: scores[:, token_id] += self.penalty return scores def generate_text( prompt: str, model, tokenizer, config_type: str = "extract", logits_processors: LogitsProcessorList = None, ) -> str: """Unified generation. config_type: 'extract' | 'generate' | 'rank'.""" if config.LLM_BACKEND == "hf_api": return _generate_via_hf_api(prompt, config_type) messages = [{"role": "user", "content": prompt}] text = _apply_chat_template_no_think(tokenizer, messages) inputs = tokenizer(text, return_tensors="pt").to(config.DEVICE) gen_kwargs = _local_generation_kwargs(config_type) with torch.no_grad(): output_ids = model.generate( **inputs, logits_processor=logits_processors or LogitsProcessorList(), pad_token_id=tokenizer.eos_token_id, **gen_kwargs, ) # Free KV cache + intermediate buffers from unified memory after each call if config.DEVICE == "mps": torch.mps.empty_cache() gc.collect() new_tokens = output_ids[0][inputs["input_ids"].shape[-1]:] raw = tokenizer.decode(new_tokens, skip_special_tokens=True).strip() return _strip_thinking(raw)