import os os.environ.setdefault("D3_FAST", "0") import spaces # must come before torch / transformers import html import json import time from pathlib import Path import gradio as gr import torch from transformers import AutoModel try: import spaces @spaces.GPU(duration=1) def _gpu_placeholder(): return 0 except ImportError: pass MODEL_ID = "vllm-sr/d3-mini" HERE = Path(__file__).parent IMAGE_EXT = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"} VIDEO_EXT = {".mp4", ".webm", ".mov", ".mkv", ".avi"} # The d3 runtime places weights itself; load on CPU, then move to CUDA so ZeroGPU can intercept the move. # flash-linear-attention is deliberately NOT installed: its kernels are GPU-only and would block # the CPU load; transformers falls back to its (correct) reference implementation instead. model = AutoModel.from_pretrained(MODEL_ID, trust_remote_code=True, device="cpu") # model.to("cuda") # --------------------------------------------------------------------------- examples (from the model card) TEXT_STATE = ( "The order arrived damaged yesterday. The customer has a receipt and asks for a replacement today." ) TEXT_QUESTIONS = { "route": { "type": "choice", "instructions": "Which team should handle this request?", "criteria": { "returns": "Refunds, replacements and damaged deliveries", "billing": "Payments, invoices and charges", "technical": "Product setup and faults", }, }, "receipt": {"type": "noul", "instructions": "Does the customer have a receipt?"}, "urgency": { "type": "score", "instructions": "How urgent is this request?", "criteria": ["Routine", "Soon", "Today"], }, } IMAGE_STATE = "The customer says the blender arrived cracked and attached the receipt." IMAGE_QUESTIONS = { "route": TEXT_QUESTIONS["route"], "on_receipt": {"type": "noul", "instructions": "Does the receipt list the blender?"}, "payment": { "type": "choice", "instructions": "How was the order paid?", "criteria": {"card": None, "cash": None, "gift card": None}, }, } VIDEO_STATE = "A short clip from a test camera." VIDEO_QUESTIONS = { "direction": { "type": "choice", "instructions": "Which way does the square move?", "criteria": { "right": "From left to right", "left": "From right to left", "still": "It does not move", }, }, "color_change": {"type": "noul", "instructions": "Does the square change color?"}, } JSON_STATE = json.dumps( { "ticket_id": "T-4821", "channel": "email", "message": "I was charged twice for my subscription this month. Please fix it ASAP, I'm about to cancel.", "customer_tier": "premium", }, indent=2, ) JSON_QUESTIONS = { "route": TEXT_QUESTIONS["route"], "churn_risk": { "type": "noul", "instructions": "Is the customer at risk of cancelling?", }, "sentiment": { "type": "score", "instructions": "How satisfied is the customer?", "criteria": ["Very unhappy", "Unhappy", "Neutral", "Happy", "Very happy"], }, } def dumps(obj) -> str: return json.dumps(obj, indent=2, ensure_ascii=False) TEMPLATES = { "choice": { "type": "choice", "instructions": "Which option fits best?", "criteria": {"option_a": "Description of A", "option_b": "Description of B"}, }, "noul": {"type": "noul", "instructions": "Is this statement true?"}, "score": { "type": "score", "instructions": "Rate this on a scale.", "criteria": ["Low", "Medium", "High"], }, } def add_question(current: str, kind: str) -> str: """Append a template question of the given kind to the questions JSON.""" try: questions = json.loads(current) if current.strip() else {} if not isinstance(questions, dict): raise ValueError except ValueError: raise gr.Error("Fix the questions JSON before adding a new question.") base = {"choice": "my_choice", "noul": "my_yes_no", "score": "my_score"}[kind] name, i = base, 2 while name in questions: name, i = f"{base}_{i}", i + 1 questions[name] = json.loads(json.dumps(TEMPLATES[kind])) return dumps(questions) # --------------------------------------------------------------------------- rendering def _bar(label: str, p: float, best: bool, sub: str = "") -> str: pct = max(0.0, min(1.0, p)) * 100 color = "#f97316" if best else "#a78bfa" sub_html = f'{html.escape(sub)}' if sub else "" return ( '
' f'
{"" if best else ""}{html.escape(label)}{"" if best else ""}{sub_html}
' f'
' f'
{pct:.1f}%
' "
" ) def render_answers(questions: dict, answers: dict, elapsed: float, tokens: int) -> str: cards = [] for key, ans in answers.items(): q = questions.get(key, {}) title = html.escape(str(q.get("instructions") or key)) kind = ans.get("type", q.get("type", "")) badge = {"choice": "Choice", "noul": "Yes / No", "score": "Score"}.get(kind, kind) head = f'
{badge}{html.escape(key)} {title}
' if "error" in ans: body = f'
{html.escape(ans["error"])}: {html.escape(ans.get("message", ""))}
' elif kind == "noul": p = float(ans["noul"]) verdict = "Yes" if p >= 0.5 else "No" body = ( f'
{verdict}
' + _bar("Yes", p, p >= 0.5) + _bar("No", 1 - p, p < 0.5) ) elif kind == "choice": probs = ans["probabilities"] crit = q.get("criteria") or {} best = ans["choice"] body = ( f'
{html.escape(best)}' f'confidence {ans.get("confidence", 0):.2f}
' ) for k, p in sorted(probs.items(), key=lambda kv: -kv[1]): desc = crit.get(k) if isinstance(crit, dict) else None body += _bar(k, p, k == best, desc if isinstance(desc, str) else "") elif kind == "score": probs = ans["probabilities"] legend = ans.get("legend", {}) n = len(probs) best = max(probs, key=probs.get) body = ( f'
{ans["score"]:.2f} expected level on 0–{n - 1}' f' · confidence {ans.get("confidence", 0):.2f}
' ) for k, p in probs.items(): body += _bar(f"{k}: {legend.get(k, k)}", p, k == best) else: body = f"
{html.escape(dumps(ans))}
" cards.append(f'
{head}{body}
') footer = f'
{len(answers)} question(s) · {tokens} input tokens · {elapsed * 1000:.0f} ms on GPU
' return '
' + "".join(cards) + footer + "
" # --------------------------------------------------------------------------- inference def _paths(files) -> list[str]: out = [] for f in files or []: p = f if isinstance(f, str) else getattr(f, "name", None) or (f.get("path") if isinstance(f, dict) else None) if p: out.append(str(p)) return out def _gpu_duration(state, questions, images=None, videos=None): """Seconds to reserve, sized from measurements on this Space's ZeroGPU worker. Measured (48 GB `large`, torch-reference kernels, no fla): * warm text request (3 questions, 345 tokens): 68 ms of GPU work * warm 1-image request (3 questions, 2938 tokens): 551 ms * warm 1-video request (2 questions, 1720 tokens): 288 ms * the first request after boot: ~5.5 s wall — one-time first-touch cost of streaming the packed 9.08 GB of bf16 weights into VRAM. The floor of 8 s covers that first-touch (the scheduler recycles idle workers, so a fresh one shows up occasionally) plus a small margin; the per-input terms scale for big states, image stacks and long videos (decoding happens inside the GPU reservation too). A request that overruns its reservation only logs a warning, so the estimate stays lean: a smaller number ranks higher in the queue and costs every visitor less quota. """ chars = len(json.dumps(state, ensure_ascii=False, default=str)) + len( json.dumps(questions, ensure_ascii=False, default=str) ) seconds = 8 + chars / 300 + 2 * len(images or []) + (5 if videos else 0) return int(min(30, seconds)) # @spaces.GPU(duration=_gpu_duration) def _run(state, questions, images, videos): t0 = time.perf_counter() with torch.inference_mode(): result = model.system_one(state=state, questions=questions, images=images or None, videos=videos or None) return result, time.perf_counter() - t0 def decide(state: str, questions_json: str, media: list | None = None): """Answer Choice / Yes-No / Score questions about a text or JSON state, optionally with images and videos. Args: state: the input to decide about, plain text or a JSON document. questions_json: JSON object mapping question IDs to {"type": "choice"|"noul"|"score", "instructions": str, "criteria": ...}. Choice criteria map option keys to descriptions (or null); score criteria are an ordered list of 2-10 levels; noul is a yes/no question. media: optional list of image (png/jpg/webp) and video (mp4/webm/mov/mkv) file paths that every question sees. Returns: An HTML rendering of the answers and the raw System One response (answers with probabilities). """ if not state or not state.strip(): raise gr.Error("Enter a state (text or JSON) to decide about.") try: questions = json.loads(questions_json) except json.JSONDecodeError as e: raise gr.Error(f"Questions are not valid JSON: {e}") if not isinstance(questions, dict) or not questions: raise gr.Error("Questions must be a non-empty JSON object of {id: question}.") stripped = state.strip() parsed_state = state if stripped[:1] in "{[": try: parsed_state = json.loads(stripped) except json.JSONDecodeError: parsed_state = state images, videos = [], [] for p in _paths(media): ext = Path(p).suffix.lower() if ext in IMAGE_EXT: images.append(p) elif ext in VIDEO_EXT: videos.append(p) else: raise gr.Error(f"Unsupported file type: {Path(p).name}") try: result, elapsed = _run(parsed_state, questions, images, videos) except ValueError as e: raise gr.Error(str(e)) tokens = result.get("usage", {}).get("input_tokens", 0) return render_answers(questions, result["answers"], elapsed, tokens), result # --------------------------------------------------------------------------- UI CSS = """ #col-container { max-width: 1200px; margin: 0 auto; } .dark .gradio-container { color: var(--body-text-color); } .d3-wrap { display: flex; flex-direction: column; gap: 12px; } .d3-card { border: 1px solid var(--border-color-primary); border-radius: 10px; padding: 12px 14px; background: var(--block-background-fill); } .d3-head { font-size: 0.95em; margin-bottom: 6px; color: var(--body-text-color); } .d3-head code { margin: 0 6px; } .d3-badge { font-size: 0.75em; padding: 2px 8px; border-radius: 999px; background: #fde68a; color: #78350f; font-weight: 600; } .d3-verdict { font-size: 1.35em; font-weight: 700; margin: 4px 0 8px; color: var(--body-text-color); } .d3-conf { font-size: 0.6em; font-weight: 400; margin-left: 10px; opacity: 0.7; } .d3-row { display: grid; grid-template-columns: minmax(120px, 38%) 1fr 56px; gap: 8px; align-items: center; margin: 3px 0; } .d3-label { font-size: 0.9em; color: var(--body-text-color); overflow-wrap: anywhere; } .d3-sub { display: block; font-size: 0.8em; opacity: 0.65; } .d3-track { height: 12px; border-radius: 6px; background: var(--background-fill-secondary); overflow: hidden; } .d3-fill { height: 100%; border-radius: 6px; } .d3-pct { font-variant-numeric: tabular-nums; font-size: 0.85em; text-align: right; color: var(--body-text-color); } .d3-err { color: #dc2626; } .d3-foot { font-size: 0.8em; opacity: 0.7; color: var(--body-text-color); } """ with gr.Blocks(title="d3-mini Decision") as demo: with gr.Column(elem_id="col-container"): gr.Markdown( "# d3-mini: multimodal decision model\n" "Give [vllm-sr/d3-mini](https://huggingface.co/vllm-sr/d3-mini) (4.54B) an input — text or JSON, " "plus optional images and videos — and any number of **Choice**, **Yes / No** and **Score** questions. " "It answers them all in one call with a probability for every option, without generating text. " "Part of [vLLM Semantic Router](https://github.com/vllm-project/semantic-router)." ) with gr.Row(): with gr.Column(scale=1): state = gr.Textbox( label="State (text or JSON)", lines=5, placeholder="Describe the situation, paste a ticket, or a JSON document…", ) media = gr.File( label="Images / videos (optional, several allowed)", file_count="multiple", file_types=sorted(IMAGE_EXT | VIDEO_EXT), ) questions = gr.Code( label="Questions (JSON)", language="json", value=dumps(TEXT_QUESTIONS), lines=16, ) with gr.Row(): add_choice = gr.Button("+ Choice", size="sm") add_noul = gr.Button("+ Yes / No", size="sm") add_score = gr.Button("+ Score", size="sm") run = gr.Button("Decide", variant="primary") with gr.Column(scale=1): answers_html = gr.HTML(label="Answers") with gr.Accordion("Raw response (JSON)", open=False): raw = gr.JSON(label="System One response") with gr.Accordion("Question format", open=False): gr.Markdown( "- `choice`: `criteria` maps option keys to descriptions (or `null`), 1–255 options.\n" "- `noul`: yes / no; optional `criteria` may describe `\"true\"` and `\"false\"`.\n" "- `score`: `criteria` is an ordered list of 2–10 levels; the answer is the expected level.\n\n" "Images are read at up to 1.6 MP; videos at 2 fps (max 32 frames, 0.2 MP each)." ) add_choice.click(lambda q: add_question(q, "choice"), questions, questions, api_visibility="private") add_noul.click(lambda q: add_question(q, "noul"), questions, questions, api_visibility="private") add_score.click(lambda q: add_question(q, "score"), questions, questions, api_visibility="private") run.click(decide, [state, questions, media], [answers_html, raw], api_name="decide") demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)