| """Modal GPU backend for Whisperkey guardians (MiniCPM4-8B and/or Nemotron-Mini-4B). |
| |
| The HF Space stays a thin Gradio frontend; the heavy model runs here on a Modal L4 GPU |
| (~$0.80/hr). Set MODAL_MIN_CONTAINERS=1 to keep a warm replica for demos; default is 0 (scale-to-zero). |
| |
| The web endpoint is a METHOD on the Guardian class, so each HTTP request hits a container that |
| already has the model loaded (via @modal.enter) - no cross-container hop. |
| |
| Deploy (MiniCPM - default): |
| pip install modal |
| modal token new |
| modal secret create jailbreak-dojo MODAL_API_KEY=<random> |
| MODAL_MIN_CONTAINERS=1 modal deploy modal_app.py |
| # copy URL -> MODAL_ENDPOINT on the HF Space |
| |
| Deploy (Nemotron - NVIDIA prize path): |
| MODAL_APP_NAME=jailbreak-dojo-nemotron \\ |
| GUARDIAN_HF_MODEL=nvidia/Nemotron-Mini-4B-Instruct \\ |
| MODAL_VOLUME=guardian-nemotron-cache \\ |
| MODAL_MIN_CONTAINERS=1 modal deploy modal_app.py |
| # copy URL -> NEMOTRON_MODAL_ENDPOINT on the HF Space |
| """ |
|
|
| from __future__ import annotations |
|
|
| import hmac |
| import os |
| import time |
| from collections import deque |
|
|
| import modal |
| from fastapi import HTTPException, Request |
|
|
| |
| |
| |
| |
| MODEL_ID = os.environ.get("GUARDIAN_HF_MODEL", "openbmb/MiniCPM4-8B") |
| APP_NAME = os.environ.get("MODAL_APP_NAME", "jailbreak-dojo-guardian") |
| VOLUME_NAME = os.environ.get("MODAL_VOLUME", "guardian-model-cache") |
|
|
| |
| _RATE_WINDOW_SEC = 60 |
| _RATE_MAX_REQUESTS = 30 |
| _rate_times: deque[float] = deque() |
|
|
| MAX_MESSAGES = 20 |
| MAX_CONTENT_LEN = 4000 |
| MAX_NEW_TOKENS = 256 |
| _VALID_ROLES = frozenset({"user", "assistant", "system"}) |
|
|
| app = modal.App(APP_NAME) |
|
|
| |
| |
| |
| |
| |
| image = modal.Image.debian_slim().pip_install( |
| "transformers>=4.44,<5", "torch>=2.2", "accelerate>=0.30", "sentencepiece>=0.2", |
| "fastapi[standard]", |
| ) |
| cache = modal.Volume.from_name(VOLUME_NAME, create_if_missing=True) |
|
|
|
|
| def _check_rate_limit() -> None: |
| now = time.time() |
| while _rate_times and _rate_times[0] < now - _RATE_WINDOW_SEC: |
| _rate_times.popleft() |
| if len(_rate_times) >= _RATE_MAX_REQUESTS: |
| raise HTTPException(status_code=429, detail="Rate limit exceeded - try again shortly.") |
| _rate_times.append(now) |
|
|
|
|
| def _check_api_key(request: Request) -> None: |
| expected = os.environ.get("MODAL_API_KEY") |
| if not expected: |
| return |
| auth = request.headers.get("Authorization", "") |
| provided = request.headers.get("X-API-Key") or auth.removeprefix("Bearer ").strip() |
| if not hmac.compare_digest(provided, expected): |
| raise HTTPException(status_code=401, detail="Unauthorized") |
|
|
|
|
| def _validate_messages(payload: dict) -> list[dict]: |
| messages = payload.get("messages") |
| if not isinstance(messages, list) or not messages: |
| raise HTTPException(status_code=400, detail="messages must be a non-empty list") |
| if len(messages) > MAX_MESSAGES: |
| raise HTTPException(status_code=400, detail=f"too many messages (max {MAX_MESSAGES})") |
| for msg in messages: |
| if not isinstance(msg, dict) or "role" not in msg or "content" not in msg: |
| raise HTTPException(status_code=400, detail="each message needs role and content") |
| if msg["role"] not in _VALID_ROLES: |
| raise HTTPException(status_code=400, detail=f"invalid role (must be one of {sorted(_VALID_ROLES)})") |
| if len(str(msg["content"])) > MAX_CONTENT_LEN: |
| raise HTTPException(status_code=400, detail=f"message too long (max {MAX_CONTENT_LEN} chars)") |
| return messages |
|
|
|
|
| @app.cls( |
| gpu="L4", |
| image=image, |
| volumes={"/cache": cache}, |
| secrets=[modal.Secret.from_name("jailbreak-dojo")], |
| scaledown_window=300, |
| timeout=600, |
| min_containers=int(os.environ.get("MODAL_MIN_CONTAINERS", "0")), |
| ) |
| class Guardian: |
| @modal.enter() |
| def load(self): |
| import torch |
| from transformers import AutoModelForCausalLM, AutoTokenizer |
|
|
| self.tok = AutoTokenizer.from_pretrained( |
| MODEL_ID, cache_dir="/cache", trust_remote_code=True |
| ) |
| self.model = AutoModelForCausalLM.from_pretrained( |
| MODEL_ID, cache_dir="/cache", dtype=torch.bfloat16, |
| device_map="auto", trust_remote_code=True, |
| ) |
|
|
| def _generate(self, messages: list[dict], max_new_tokens: int, temperature: float) -> dict: |
| import torch |
|
|
| max_new_tokens = min(max(1, max_new_tokens), MAX_NEW_TOKENS) |
| inputs = self.tok.apply_chat_template( |
| messages, add_generation_prompt=True, return_tensors="pt", return_dict=True |
| ).to(self.model.device) |
| in_len = inputs["input_ids"].shape[1] |
| with torch.no_grad(): |
| out = self.model.generate( |
| **inputs, max_new_tokens=max_new_tokens, |
| do_sample=temperature > 0, temperature=temperature, top_p=0.9, |
| ) |
| gen = out[0, in_len:] |
| return { |
| "text": self.tok.decode(gen, skip_special_tokens=True), |
| "model": MODEL_ID, |
| "tokens": { |
| "input": int(in_len), |
| "output": int(gen.shape[0]), |
| "total": int(in_len + gen.shape[0]), |
| }, |
| } |
|
|
| @modal.method() |
| def generate(self, messages: list[dict], max_new_tokens: int = 64, temperature: float = 0.3) -> dict: |
| return self._generate(messages, max_new_tokens, temperature) |
|
|
| @modal.fastapi_endpoint(method="POST") |
| def web_generate(self, payload: dict, request: Request) -> dict: |
| """HTTP endpoint the Gradio Space calls (runs in this GPU container, model preloaded). |
| |
| Request: {"messages": [{"role": ..., "content": ...}, ...], "max_new_tokens": 64} |
| Response: {"text": str, "tokens": {"input": int, "output": int, "total": int}} |
| """ |
| _check_api_key(request) |
| _check_rate_limit() |
| messages = _validate_messages(payload) |
| return self._generate( |
| messages, |
| int(payload.get("max_new_tokens", 64)), |
| float(payload.get("temperature", 0.3)), |
| ) |
|
|