"""Gemma 4 E2B (QAT Mobile) multimodal chat demo for HF Spaces ZeroGPU.""" # `spaces` MUST be imported before torch / transformers: it monkey-patches # torch.cuda.*, which must happen before CUDA is initialized in this process. import spaces # noqa: F401 import os import threading from collections.abc import Iterator from typing import Any import torch import gradio as gr from transformers import ( AutoProcessor, AutoModelForMultimodalLM, TextIteratorStreamer, ) MODEL_ID = os.environ.get("MODEL_ID", "unsloth/gemma-4-E2B-it-qat-mobile") # HF_TOKEN is only required if MODEL_ID points at a gated repo. This model is # public, so it can be left unset. HF_TOKEN = os.environ.get("HF_TOKEN") DEFAULT_SYSTEM = ( "You are Gemma 4, a friendly, helpful multimodal assistant. " "Be concise and accurate." ) # Load eagerly at module scope. On ZeroGPU this call is intercepted: weights are # packed to disk here, then streamed into VRAM on the first @spaces.GPU entry. print(f"[app] loading model: {MODEL_ID}", flush=True) processor = AutoProcessor.from_pretrained(MODEL_ID, token=HF_TOKEN) model = AutoModelForMultimodalLM.from_pretrained( MODEL_ID, dtype="auto", device_map="cuda", token=HF_TOKEN, ) model.eval() print("[app] model ready", flush=True) def _user_content_blocks(value: Any) -> list[dict]: """Turn a Gradio multimodal user value into Gemma 4 content blocks. Gradio hands us either a plain string (text-only turn) or a dict with ``{"text": str, "files": [paths]}``. Gemma 4 expects images *before* text. """ if value is None: return [] if isinstance(value, str): text = value.strip() return [{"type": "text", "text": text}] if text else [] if isinstance(value, dict): blocks: list[dict] = [] for path in value.get("files") or []: blocks.append({"type": "image", "url": path}) text = (value.get("text") or "").strip() if text: blocks.append({"type": "text", "text": text}) return blocks return [] def _build_messages( history: list[dict], message: dict, system_prompt: str, enable_thinking: bool, ) -> list[dict]: messages: list[dict] = [ {"role": "system", "content": system_prompt.strip() or DEFAULT_SYSTEM} ] for turn in history: role = turn.get("role") content = turn.get("content") if role == "user": blocks = _user_content_blocks(content) if blocks: messages.append({"role": "user", "content": blocks}) elif role == "assistant" and isinstance(content, str) and content.strip(): # Per Gemma 4 best practice: prior assistant turns carry only the # final answer, never the thinking trace. messages.append({"role": "assistant", "content": content}) blocks = _user_content_blocks(message) messages.append({"role": "user", "content": blocks}) return messages @spaces.GPU(duration=120) def respond( message: dict, history: list[dict], system_prompt: str, enable_thinking: bool, max_new_tokens: int, temperature: float, top_p: float, top_k: int, ) -> Iterator[str]: """Stream a multimodal chat completion from Gemma 4 E2B. Yielded strings are the growing assistant reply (shown live in the UI). """ user_blocks = _user_content_blocks(message) if not user_blocks: yield "Please enter a message or attach an image first." return messages = _build_messages(history, message, system_prompt, enable_thinking) inputs = processor.apply_chat_template( messages, tokenize=True, return_dict=True, return_tensors="pt", add_generation_prompt=True, enable_thinking=enable_thinking, ).to(model.device) streamer = TextIteratorStreamer( processor.tokenizer, skip_prompt=True, skip_special_tokens=True, ) do_sample = temperature > 0 gen_kwargs = dict( inputs, max_new_tokens=int(max_new_tokens), top_p=float(top_p), top_k=int(top_k), do_sample=do_sample, streamer=streamer, ) if do_sample: gen_kwargs["temperature"] = float(temperature) worker = threading.Thread(target=model.generate, kwargs=gen_kwargs) worker.start() partial = "" try: for chunk in streamer: partial += chunk yield partial finally: worker.join() demo = gr.ChatInterface( fn=respond, multimodal=True, title="Gemma 4 E2B (QAT Mobile) \u00b7 Multimodal Demo", description=( "Chat with `unsloth/gemma-4-E2B-it-qat-mobile` \u2014 a 2.3B-effective-parameter " "multimodal Gemma 4 model (text + image). Upload an image and ask about it, " "or just chat. The first reply after a cold start may be slow while ZeroGPU " "streams the weights into VRAM." ), additional_inputs=[ gr.Textbox( value=DEFAULT_SYSTEM, label="System prompt", lines=2, ), gr.Checkbox(value=False, label="Enable thinking mode"), gr.Slider(64, 2048, value=512, step=64, label="Max new tokens"), gr.Slider(0.0, 1.5, value=1.0, step=0.05, label="Temperature (0 = greedy)"), gr.Slider(0.1, 1.0, value=0.95, step=0.05, label="Top-p"), gr.Slider(0, 128, value=64, step=1, label="Top-k"), ], examples=[ [{"text": "Write a short poem about a curious cat.", "files": []}], [{"text": "Explain quantization-aware training in two sentences.", "files": []}], [{"text": "Give me three name ideas for a friendly code-review bot.", "files": []}], ], cache_examples=False, fill_height=True, ) if __name__ == "__main__": demo.launch(mcp_server=True)