"""In-process transformers backend for HF Spaces ZeroGPU. Loaded only when PHANTOM_GRID_LLM_PROVIDER=zerogpu_transformers. Follows the canonical ZeroGPU pattern documented at https://huggingface.co/docs/hub/en/spaces-zerogpu : "Models must be placed on `cuda` at the root module level. A PyTorch CUDA emulation mode is enabled outside @spaces.GPU functions, allowing CUDA operations without a real GPU. Inside @spaces.GPU, real CUDA is used." So we load directly to `cuda` here; ZeroGPU's emulation handles it at import, and the real device is attached only while the @spaces.GPU function runs. """ from __future__ import annotations import os from typing import Any _MODEL_ID = os.getenv("PHANTOM_GRID_ZEROGPU_MODEL_ID", "openbmb/MiniCPM4.1-8B") _DEFAULT_DURATION = int(os.getenv("PHANTOM_GRID_ZEROGPU_DURATION", "90")) # ZeroGPU's `spaces` package patches torch's CUDA at import time. We must import # it BEFORE torch so the emulation is in place when we then load to 'cuda'. try: import spaces # type: ignore _spaces_gpu = spaces.GPU _ON_ZEROGPU = True except ImportError: # local dev without the spaces package def _spaces_gpu(*_args, **_kwargs): def _wrap(fn): return fn return _wrap _ON_ZEROGPU = False import threading # noqa: E402 import torch # noqa: E402 — must come after `import spaces` # openbmb/MiniCPM4.1-8B's modeling_minicpm.py (loaded via trust_remote_code=True) # imports `is_torch_fx_available` from transformers.utils.import_utils, which # was removed in transformers >= 5.0. Inject a shim BEFORE we touch # AutoModel/AutoTokenizer so the trust_remote_code import succeeds. # torch.fx has been built-in since torch 2.1, so returning True is correct. import transformers.utils.import_utils as _tx_import_utils # noqa: E402 if not hasattr(_tx_import_utils, "is_torch_fx_available"): _tx_import_utils.is_torch_fx_available = lambda: True from transformers import AutoModelForCausalLM, AutoTokenizer # noqa: E402 _LOAD_ERROR: str | None = None _model = None _tokenizer = None _load_done = threading.Event() def _log(msg: str) -> None: """stdout flush so progress shows up in the HF container log immediately.""" print(f"[zerogpu_backend] {msg}", flush=True) def _load_model() -> None: """Heavy CPU work (download ~16 GB safetensors + load on CPU). Runs in a daemon thread so import returns immediately and the HTTP server can serve /api/setup/status without blocking. NOTE: We deliberately do NOT call `.to('cuda')` here. ZeroGPU's CUDA emulation only intercepts calls made from the main thread; a background thread that hits `to('cuda')` triggers a real `torch._C._cuda_init()` that fails because no GPU is attached yet. Instead, the @spaces.GPU function moves the model to cuda on each call, which is the safe pattern for off-main-thread initialization. """ global _model, _tokenizer, _LOAD_ERROR try: _log(f"loading tokenizer for {_MODEL_ID}") _tokenizer = AutoTokenizer.from_pretrained(_MODEL_ID, trust_remote_code=True) _log("tokenizer ready; loading model weights (this downloads ~16 GB on first run)") model = AutoModelForCausalLM.from_pretrained( _MODEL_ID, torch_dtype=torch.bfloat16, trust_remote_code=True, low_cpu_mem_usage=True, ) model.eval() _model = model _log("READY (model on CPU; will move to cuda inside @spaces.GPU)") except Exception as exc: # pragma: no cover _LOAD_ERROR = f"{exc.__class__.__name__}: {exc}" _log(f"LOAD FAILED: {_LOAD_ERROR}") finally: _load_done.set() _loader_thread = threading.Thread(target=_load_model, name="zerogpu-model-loader", daemon=True) _loader_thread.start() _moved_to_cuda = False @_spaces_gpu(duration=_DEFAULT_DURATION) def _generate_on_gpu( messages: list[dict[str, Any]], max_new_tokens: int, temperature: float, json_mode: bool, ) -> str: global _moved_to_cuda if _model is None or _tokenizer is None: raise RuntimeError(_LOAD_ERROR or "zerogpu backend not initialized") # First call inside this ZeroGPU slot: move the CPU-resident model to the # real GPU. Cheap on subsequent calls (no-op if already on cuda). if _ON_ZEROGPU and not _moved_to_cuda: _model.to("cuda") _moved_to_cuda = True target_device = "cuda" if _ON_ZEROGPU else _model.device prompt = _tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True, ) inputs = _tokenizer(prompt, return_tensors="pt").to(target_device) do_sample = temperature > 0 gen_kwargs: dict[str, Any] = { "max_new_tokens": max_new_tokens, "do_sample": do_sample, "pad_token_id": _tokenizer.eos_token_id, # KV cache works with the transformers 4.55-4.57 pin in requirements.txt # — that's the range where the bundled modeling_minicpm.py's cache # ops match the installed transformers API. Decoding is O(n) per step # instead of O(n^2), so we can also raise the token cap. "use_cache": True, } if do_sample: gen_kwargs["temperature"] = temperature gen_kwargs["top_p"] = 0.9 with torch.inference_mode(): outputs = _model.generate(**inputs, **gen_kwargs) new_tokens = outputs[0][inputs["input_ids"].shape[1]:] text = _tokenizer.decode(new_tokens, skip_special_tokens=True).strip() text = _strip_think_tags(text) if json_mode: text = _extract_json(text) return text _THINK_TAG_RE = None def _strip_think_tags(text: str) -> str: """Remove ... blocks (incl. empty ones) that MiniCPM4 and other hybrid-reasoning models emit even when /no_think is present in the system prompt. Also strips a stray opening/closing tag if unmatched.""" import re global _THINK_TAG_RE if _THINK_TAG_RE is None: _THINK_TAG_RE = re.compile(r".*?\s*", re.DOTALL | re.IGNORECASE) cleaned = _THINK_TAG_RE.sub("", text) cleaned = re.sub(r"\s*", "", cleaned, flags=re.IGNORECASE) return cleaned.strip() def _extract_json(text: str) -> str: cleaned = text.strip() if cleaned.startswith("```"): cleaned = cleaned.strip("`") if cleaned.lower().startswith("json"): cleaned = cleaned[4:].strip() start = cleaned.find("{") end = cleaned.rfind("}") if start >= 0 and end > start: return cleaned[start : end + 1] return text _NO_THINK_HINTS = ("minicpm4", "minicpm-4", "minicpm_4", "qwen3", "qwen-3") def _maybe_inject_no_think(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: """MiniCPM4 / Qwen3 emit hidden blocks that eat the token budget on small max_new_tokens. Append '/no_think' to the system turn so the model skips it. Mirrors llm/omni_client.py:_ensure_no_think.""" if not any(hint in _MODEL_ID.lower() for hint in _NO_THINK_HINTS): return messages directive = "/no_think" for item in messages: if item.get("role") == "system" and isinstance(item.get("content"), str): if directive not in item["content"]: item["content"] = f"{item['content'].rstrip()} {directive}".strip() return messages return [{"role": "system", "content": directive}, *messages] # Hard ceiling on tokens per generation. With use_cache=False (forced because # the openbmb modeling code's cache path is broken on current transformers), # attention is O(n^2) per step. Combined with the ZeroGPU 90-120 s per-call # slot ceiling, anything above ~256 tokens risks getting aborted mid-stream. # Witness chat and short story segments easily fit; longer chunks (full case # briefings) will be truncated. Worth accepting for a working demo. _ZEROGPU_MAX_TOKENS = int(os.getenv("PHANTOM_GRID_ZEROGPU_MAX_TOKENS", "384")) def chat_completion( messages: list[dict[str, Any]], *, temperature: float = 0.4, max_tokens: int = 512, json_mode: bool = False, ) -> str: """Synchronous drop-in for an OpenAI chat-completions POST. Waits for the background model load to finish on the first call (no-op afterwards).""" _load_done.wait() if _LOAD_ERROR: raise RuntimeError(f"ZeroGPU model failed to load: {_LOAD_ERROR}") messages = _maybe_inject_no_think([dict(m) for m in messages]) capped = min(max_tokens, _ZEROGPU_MAX_TOKENS) return _generate_on_gpu(messages, capped, temperature, json_mode) def health() -> dict[str, Any]: """Non-blocking: reports whether the background load is done. NEVER waits.""" loading = not _load_done.is_set() return { "reachable": True, "ready": _model is not None and _LOAD_ERROR is None and not loading, "detail": { "model_id": _MODEL_ID, "on_zerogpu": _ON_ZEROGPU, "loading": loading, "load_error": _LOAD_ERROR, }, }