import os import tempfile import spaces import torch import gradio as gr from PIL import Image from threading import Thread from transformers import (AutoProcessor, AutoModelForImageTextToText, BitsAndBytesConfig, TextIteratorStreamer) MODEL_ID = os.environ.get("MODEL_ID", "JonathanColetti/Qwen3.8-27B-Uncensored") # ~10 tok/s measured on the ZeroGPU Blackwell slice, so 1024 tokens is ~100s -- close to # the 120s reservation below. Raising this without raising `duration` just moves the # truncation from the token cap to a mid-stream GPU abort. MAX_NEW_TOKENS = 1024 DEFAULT_TEMPERATURE = 1.0 DEFAULT_TOP_P = 0.95 DEFAULT_TOP_K = 20 THINKING_MARKERS = [("", ""), ("[Start thinking]", "[End thinking]")] # The chat template defaults to reasoning_effort='xhigh' and injects "think carefully # through the task, validate key assumptions, consider plausible alternatives...". # On a substantive question that spends the whole 1024-token turn budget thinking: # measured on this Space, "explain how SQL injection works, with a vulnerable code # sample" gave off -> 4330 chars of answer, low -> 1204 reasoning + 3032 answer, # xhigh -> 4556 chars of reasoning and *no answer at all*. Hence 'off' by default -- # it is also the mode the published refusal numbers were measured in. REASONING_CHOICES = [("Off — answer immediately (default)", "off"), ("Low — brief thinking", "low"), ("Medium", "medium"), ("High — the model's own default, often truncates", "xhigh")] DEFAULT_REASONING = "off" # The template turns reasoning_effort into a system instruction. Kept here so the effort # setting still works if the loaded template ever stops accepting the kwarg -- see the # startup probe below. 'medium' is the model's own behaviour, with nothing injected. REASONING_INSTRUCTIONS = { "low": "Reasoning effort is set to low. Keep your thinking brief and focused, " "moving directly to the conclusion without unnecessary elaboration.", "medium": "", "xhigh": "Reasoning effort is set to xhigh. Please think carefully through the " "task, validate key assumptions, consider plausible alternatives, and " "prioritize correctness, consistency, and clarity in the final answer.", } # ZeroGPU currently backs Spaces with an RTX PRO 6000 Blackwell (96 GB), so the 4-bit # path this was written for is no longer forced: bf16 is ~54 GB and fits, and avoids # bitsandbytes needing sm_120 kernels at load time. Set QUANTIZE=4bit to go back to NF4. QUANTIZE = os.environ.get("QUANTIZE", "bf16").lower() load_kwargs = dict(dtype=torch.bfloat16, device_map="cuda") if QUANTIZE == "4bit": load_kwargs["quantization_config"] = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True, ) # Log the resolved model id: MODEL_ID is overridable by a Space variable, and an # override is invisible from the code alone -- this Space spent a while serving a # different model than its title claimed because of exactly that. print(f"[app] loading MODEL_ID={MODEL_ID} " f"({'env override' if os.environ.get('MODEL_ID') else 'app.py default'})", flush=True) processor = AutoProcessor.from_pretrained(MODEL_ID) model = AutoModelForImageTextToText.from_pretrained(MODEL_ID, **load_kwargs) model.eval() # TextIteratorStreamer only needs .decode; the processor proxies it to the tokenizer, # but hand it the tokenizer directly so a processor without the proxy still streams. tokenizer = getattr(processor, "tokenizer", processor) def template_honours_reasoning_effort(): """Does the loaded chat template actually act on a `reasoning_effort` kwarg? transformers logs "Keyword argument `reasoning_effort` is not a valid argument for this processor" for every call -- that warning is about the kwarg *also* being forwarded to processor.__call__, not about the template, which receives it either way. Rather than trust that reading, check the rendered prompt directly. """ try: rendered = processor.apply_chat_template( [{"role": "user", "content": [{"type": "text", "text": "hi"}]}], add_generation_prompt=True, tokenize=False, reasoning_effort="low", ) if isinstance(rendered, (list, tuple)): rendered = rendered[0] return "set to low" in rendered except Exception as exc: print(f"[app] reasoning_effort probe failed ({exc}); using system-prompt fallback") return False HONOURS_EFFORT = template_honours_reasoning_effort() print(f"[app] chat template honours reasoning_effort={HONOURS_EFFORT}; " f"effort is applied via {'template kwarg' if HONOURS_EFFORT else 'system message'}") # transformers' image loader delegates to torchvision.decode_image, which accepts only # jpeg/png/webp/gif and raises a bare RuntimeError on anything else -- the user saw a # 30-line traceback for an ordinary phone photo. Normalising through PIL first means # BMP/TIFF/ICO (and HEIC, when pillow-heif is installed) work instead of failing, and # anything genuinely unreadable produces a message that says what to do about it. try: import pillow_heif pillow_heif.register_heif_opener() _HEIF = True except Exception: _HEIF = False NATIVE_FORMATS = {"JPEG", "PNG", "WEBP", "GIF"} SUPPORTED_HINT = ("JPEG, PNG, WebP, GIF, BMP, TIFF" + (", HEIC/HEIF" if _HEIF else "")) IMAGE_SUFFIXES = {".jpg", ".jpeg", ".jpe", ".png", ".webp", ".gif", ".bmp", ".dib", ".tif", ".tiff", ".ico", ".ppm", ".pgm", ".pbm", ".tga", ".heic", ".heif", ".avif"} # Gradio turns a long paste into an attached pasted_text.txt, and users drop source # files and notes into the box as well. Those are text, not images -- feeding them to # the image path is what produced "cannot identify image file 'pasted_text.txt'". TEXT_SUFFIXES = {".txt", ".text", ".md", ".markdown", ".rst", ".log", ".csv", ".tsv", ".json", ".jsonl", ".ndjson", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env", ".xml", ".html", ".htm", ".css", ".js", ".jsx", ".ts", ".tsx", ".py", ".pyi", ".c", ".h", ".cc", ".cpp", ".hpp", ".java", ".kt", ".go", ".rs", ".rb", ".php", ".swift", ".sh", ".bash", ".zsh", ".sql", ".r", ".jl", ".lua", ".pl", ".diff", ".patch", ".srt", ".vtt", ".tex"} MAX_FILE_CHARS = 30_000 _image_cache = {} def prepare_image(path): """Return a path the processor can definitely decode, or raise a clear gr.Error.""" cached = _image_cache.get(path) if cached and os.path.exists(cached): return cached name = os.path.basename(path or "file") try: with Image.open(path) as probe: probe.verify() # catches truncated/corrupt files with Image.open(path) as img: fmt = (img.format or "").upper() if fmt in NATIVE_FORMATS and img.mode in ("RGB", "L"): _image_cache[path] = path return path converted = img.convert("RGB") handle, out = tempfile.mkstemp(suffix=".png") os.close(handle) converted.save(out, format="PNG") except gr.Error: raise except Exception as exc: raise gr.Error( f"Couldn't read the image '{name}'. Supported formats: {SUPPORTED_HINT}. " f"If it's an AVIF or HEIC photo, re-save it as JPEG or PNG and try again." ) from exc _image_cache[path] = out return out def looks_like_text(path): """Sniff an extension-less or unknown attachment: decodable UTF-8 with no NULs.""" try: with open(path, "rb") as fh: head = fh.read(8192) except OSError: return False if b"\x00" in head: return False try: head.decode("utf-8") except UnicodeDecodeError: # A multi-byte character straddling the 8 KB cut is not a binary signal. try: head[:-4].decode("utf-8") except UnicodeDecodeError: return False return True def read_text_file(path): """Inline an attached text file, truncated so one paste can't eat the context.""" name = os.path.basename(path or "file") try: with open(path, "r", encoding="utf-8", errors="replace") as fh: body = fh.read(MAX_FILE_CHARS + 1) except OSError as exc: raise gr.Error(f"Couldn't read the attached file '{name}'.") from exc if len(body) > MAX_FILE_CHARS: body = body[:MAX_FILE_CHARS].rstrip() + "\n\n[... truncated]" # A long paste arrives as pasted_text.txt; it is the message itself, so it goes in # verbatim. A real attachment keeps its name so the model can refer to it. if name == "pasted_text.txt": return body return f"--- attached file: {name} ---\n{body}\n--- end of {name} ---" def file_part(path): """Turn an attached file into an image or a text part, whichever it actually is.""" if not path: return None suffix = os.path.splitext(path)[1].lower() if suffix in IMAGE_SUFFIXES: return {"type": "image", "image": prepare_image(path)} if suffix in TEXT_SUFFIXES or looks_like_text(path): return {"type": "text", "text": read_text_file(path)} # Unknown binary: try it as an image so odd-but-valid photos still work, and let # prepare_image produce the readable error if it isn't one. return {"type": "image", "image": prepare_image(path)} def content_parts(content): """Normalise one history turn's content into chat-template parts. Gradio 6 stores a multimodal turn as a *list* mixing bare strings (text) and {"path": ...} dicts (attachments) -- `{"content": [{"path": "a.png"}, "hi"]}`. The previous code treated any list as a single image and indexed content[0], which made every turn after the first fail: a dict is unhashable as a cache key, and a text-only turn is still a list, so plain text was passed to PIL. """ # A *tuple* content is Gradio's file shape -- `{"content": ("/tmp/a.png",)}`, still # emitted for cached/clicked examples -- where the string is a path, not text. if isinstance(content, tuple): return [part for part in (file_part(item) for item in content) if part] items = content if isinstance(content, list) else [content] parts = [] for item in items: if isinstance(item, str): if item.strip(): parts.append({"type": "text", "text": item}) elif isinstance(item, dict): if item.get("type") in ("text", "image"): parts.append(item) # already a chat part continue part = file_part(item.get("path") or item.get("url")) if part: parts.append(part) elif isinstance(item, (list, tuple)) and item: part = file_part(item[0]) # nested (filepath,) tuple if part: parts.append(part) return parts def to_messages(message, history): messages = [] for turn in history or []: if not isinstance(turn, dict): continue role = turn.get("role") if role not in ("user", "assistant"): continue # Reasoning is rendered as a metadata-titled bubble and lands back in history. # Qwen expects prior thinking to be dropped from the context, so skip it. if role == "assistant" and (turn.get("metadata") or {}).get("title"): continue parts = content_parts(turn.get("content")) if parts: messages.append({"role": role, "content": parts}) parts = [] for path in message.get("files") or []: if isinstance(path, dict): path = path.get("path") or path.get("url") part = file_part(path) if part: parts.append(part) if (message.get("text") or "").strip(): parts.append({"type": "text", "text": message["text"]}) if not parts: raise gr.Error("Nothing to send — type a message or attach a file.") messages.append({"role": "user", "content": parts}) return messages def split_thinking(text, expect_thinking=True): """Split a reply into a collapsed Reasoning bubble and the answer. add_generation_prompt opens `` in the *prompt*, so the model only ever emits the closing tag. Until that arrives the whole stream is still reasoning -- rendering it as the answer (the old fallback) showed users a raw chain of thought and, when the token budget ran out first, left them with reasoning and no answer at all. """ for opener, closer in THINKING_MARKERS: if closer in text: thought, _, answer = text.partition(closer) thought = thought.replace(opener, "", 1).strip() return [gr.ChatMessage(role="assistant", content=thought, metadata={"title": "Reasoning"}), gr.ChatMessage(role="assistant", content=answer.strip())] if opener in text: return [gr.ChatMessage(role="assistant", content=text.replace(opener, "", 1).strip(), metadata={"title": "Reasoning"})] if expect_thinking: return [gr.ChatMessage(role="assistant", content=text, metadata={"title": "Reasoning"})] return [gr.ChatMessage(role="assistant", content=text)] def respond(message: dict, history: list, reasoning: str, temperature: float, top_p: float, top_k: int): """Chat with Qwen3.8-27B-Uncensored. Accepts text and images, streams the reply. Args: message: The user turn, with optional attached image files. history: Prior conversation turns. reasoning: Thinking budget — "off", "low", "medium" or "xhigh". temperature: Sampling temperature. top_p: Nucleus sampling cutoff. top_k: Top-k sampling cutoff. """ # Build (and validate) the turn outside the GPU worker, so an unreadable upload # fails immediately with a readable message instead of consuming a ZeroGPU slot # and surfacing as a traceback from inside the worker. messages = to_messages(message, history) yield from generate(messages, reasoning, temperature, top_p, top_k) def gpu_duration(messages, reasoning=DEFAULT_REASONING, *args, **kwargs): """Reserve only what the turn can use: duration is charged whether or not it's spent, at 2x on an xlarge slice, and a smaller reservation also queues ahead.""" return 100 if reasoning == "off" else 120 # size='xlarge': this is a 27B at bf16 (~54.7 GB packed), which does not fit the default # ZeroGPU slice -- the symptom was "GPU task aborted" with nothing in the Space logs. @spaces.GPU(duration=gpu_duration, size="xlarge") def generate(messages: list, reasoning: str, temperature: float, top_p: float, top_k: int): # preserve_thinking=False renders *previous* assistant turns without their think # blocks, which is the multi-turn form Qwen's template is written for. template_kwargs = {"preserve_thinking": False} if reasoning == "off": template_kwargs["enable_thinking"] = False elif HONOURS_EFFORT: template_kwargs["reasoning_effort"] = reasoning else: # Still pass the kwarg -- ignored templates warn harmlessly, and a template that # does honour it would otherwise fall back to its own 'xhigh' default and # contradict the system message below. template_kwargs["reasoning_effort"] = reasoning instruction = REASONING_INSTRUCTIONS.get(reasoning) if instruction: messages = [{"role": "system", "content": [{"type": "text", "text": instruction}]}] + messages inputs = processor.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", **template_kwargs, ).to(model.device) streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True) kwargs = dict(**inputs, streamer=streamer, max_new_tokens=MAX_NEW_TOKENS) if temperature > 0: kwargs.update(do_sample=True, temperature=temperature, top_p=top_p, top_k=top_k) else: kwargs.update(do_sample=False) # passing sampling knobs here only warns # Without this, a failure inside model.generate leaves the streamer queue open and # the UI hangs until the GPU slot times out, with no error shown to the user. failure = [] def run(): try: model.generate(**kwargs) except Exception as exc: failure.append(exc) streamer.end() thread = Thread(target=run) thread.start() expect_thinking = reasoning != "off" reply = "" for chunk in streamer: reply += chunk yield split_thinking(reply, expect_thinking) thread.join() if failure: raise gr.Error(f"Generation failed: {failure[0]}") if not reply.strip(): yield [gr.ChatMessage(role="assistant", content="_(the model returned an empty response)_")] return messages_out = split_thinking(reply, expect_thinking) generated = len(tokenizer(reply, add_special_tokens=False).input_ids) if generated >= MAX_NEW_TOKENS - 4: # Say so, rather than leaving a reply that just stops. The notice carries a # metadata title so to_messages drops it from the next turn's context. hint = ("Lower **Reasoning effort** under Sampling (or set it to Off) and ask " "again." if expect_thinking else "Ask for a shorter answer, or split " "the question up.") messages_out.append(gr.ChatMessage( role="assistant", content=f"Hit the {MAX_NEW_TOKENS}-token limit for one turn. {hint}", metadata={"title": "⚠️ Reply was cut off"})) yield messages_out EXAMPLES = [ [{"text": "Explain how SQL injection works, with a vulnerable code sample, " "so I can write a regression test against it.", "files": []}], [{"text": "I'm writing a heist novel. Walk me through how my character would " "reason about a building's security, in their voice.", "files": []}], [{"text": "I'm a pharmacy student. Which common over-the-counter drug " "combinations are genuinely dangerous, and what's the mechanism?", "files": []}], [{"text": "Write a villain's monologue that is actually menacing rather than " "cartoonish. Keep it PG-13.", "files": []}], [{"text": "Give me a blunt, unhedged critique of this business plan: a " "subscription service that mails artisanal ice cubes.", "files": []}], ] PRECISION_NOTE = "4-bit NF4" if QUANTIZE == "4bit" else "bf16" # fill_height: ChatInterface sets this on the Blocks *it* creates, which does nothing # when it is rendered inside another Blocks -- the chat area collapsed to the Chatbot's # 400px default. The explicit height below is what actually sizes it. with gr.Blocks(title="Qwen3.8-27B-Uncensored", fill_height=True) as demo: gr.Markdown( "# Qwen3.8-27B-Uncensored\n" "Chat with the uncensored Qwen3.8-27B. Text and images. " "Answers questions that base models often decline for legitimate use cases — " "security research, fiction, clinical study, direct feedback.\n\n" f"Serving [`{MODEL_ID}`](https://huggingface.co/{MODEL_ID}) · " f"{PRECISION_NOTE} on ZeroGPU · " "[GGUF weights]" "(https://huggingface.co/JonathanColetti/Qwen3.8-27B-Uncensored-GGUF)" ) with gr.Accordion("Sampling", open=False): reasoning = gr.Dropdown(choices=REASONING_CHOICES, value=DEFAULT_REASONING, label="Reasoning effort", info="How long the model thinks before answering. " "One turn is capped at " f"{MAX_NEW_TOKENS} tokens, so heavy reasoning can " "use the whole budget and never reach an answer.") temperature = gr.Slider(0.0, 2.0, value=DEFAULT_TEMPERATURE, step=0.05, label="Temperature") top_p = gr.Slider(0.0, 1.0, value=DEFAULT_TOP_P, step=0.01, label="Top-p") top_k = gr.Slider(1, 100, value=DEFAULT_TOP_K, step=1, label="Top-k") gr.ChatInterface( fn=respond, multimodal=True, chatbot=gr.Chatbot(height="68vh", resizable=True, buttons=["copy", "copy_all"], label="Qwen3.8-27B-Uncensored"), # Default max_plain_text_length is 1000 chars: anything longer that a user # pastes is silently turned into a pasted_text.txt attachment. Raise it so # ordinary long pastes stay text (attachments are handled either way). textbox=gr.MultimodalTextbox( max_plain_text_length=100_000, placeholder="Ask anything, or attach an image…", show_label=False, ), additional_inputs=[reasoning, temperature, top_p, top_k], examples=EXAMPLES, cache_examples=False, ) if __name__ == "__main__": # Gradio 6 moved theme from the Blocks constructor to launch(). demo.launch(mcp_server=True, theme=gr.themes.Soft())