GPU hack modal
Browse files- README.md +25 -2
- gpu_shared.py +136 -0
- images.py +73 -48
- llm.py +63 -118
- modal_app.py +163 -0
- requirements-modal.txt +11 -0
- requirements.txt +3 -0
README.md
CHANGED
|
@@ -29,6 +29,29 @@ boss quest you face first.
|
|
| 29 |
Your photo and quest state live **only in your browser** (localStorage); the photo is sent to the
|
| 30 |
GPU transiently for generation and is never stored server-side.
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
|
|
|
| 29 |
Your photo and quest state live **only in your browser** (localStorage); the photo is sent to the
|
| 30 |
GPU transiently for generation and is never stored server-side.
|
| 31 |
|
| 32 |
+
### GPU backend (ZeroGPU or Modal)
|
| 33 |
+
|
| 34 |
+
The GPU work (quest JSON + images) runs through a pluggable backend chosen by the
|
| 35 |
+
`FROGQUEST_BACKEND` env var. The frontend, signatures, and behavior are identical either way.
|
| 36 |
+
|
| 37 |
+
**`zerogpu` (default).** Everything runs in-Space on ZeroGPU.
|
| 38 |
+
- Set the Space hardware to **ZeroGPU**.
|
| 39 |
+
- First quest generation cold-downloads the models, then runs fast.
|
| 40 |
+
- Subject to ZeroGPU daily quotas (anon 2 min, free 5 min, PRO 40 min per day).
|
| 41 |
+
|
| 42 |
+
**`modal` (opt-in).** The Space runs on CPU-basic and offloads GPU work to [Modal](https://modal.com)
|
| 43 |
+
(dedicated GPU, pay-per-second, no quota). Switching is **two steps** — the env var alone does NOT
|
| 44 |
+
change hardware:
|
| 45 |
+
1. Set the Space hardware to **CPU basic**.
|
| 46 |
+
2. Add a Space **variable** `FROGQUEST_BACKEND=modal`.
|
| 47 |
+
|
| 48 |
+
One-time Modal setup:
|
| 49 |
+
1. `pip install modal` and `modal token new` (authenticate).
|
| 50 |
+
2. `modal deploy modal_app.py` (deploys the `frogquest` app: an `LLM` class + a `Flux` class).
|
| 51 |
+
3. Add `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` as Space **secrets** so the Space can call Modal.
|
| 52 |
+
4. Optional: set `FROGQUEST_MODAL_FLUX_GPU` before `modal deploy` to pick the image GPU
|
| 53 |
+
(default `A10G`; `L4` cheaper; `L40S`/`A100-40GB` if it OOMs).
|
| 54 |
+
|
| 55 |
+
Optional: rename `requirements-modal.txt` → `requirements.txt` on the Space to skip installing the
|
| 56 |
+
heavy GPU libs (torch/diffusers/llama-cpp) and speed up CPU-basic builds.
|
| 57 |
|
gpu_shared.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Backend-agnostic shared source for FrogQuest's GPU work.
|
| 2 |
+
|
| 3 |
+
This module holds everything that BOTH the in-Space local path (llm.py / images.py running on
|
| 4 |
+
ZeroGPU) and the off-Space Modal path (modal_app.py running on a Modal GPU) need, so the two
|
| 5 |
+
backends can never drift: the model ids/config, the LLM system prompts, the FLUX prompt builders,
|
| 6 |
+
the JSON extractor, and the CUDA-lib preloader.
|
| 7 |
+
|
| 8 |
+
Hard rule: this file must stay importable on a CPU-only box with NO heavy GPU deps installed.
|
| 9 |
+
Only stdlib + (lazily) PIL/torch are referenced, and torch is imported inside a function. That is
|
| 10 |
+
what lets app.py import the local modules on a CPU-basic Space (FROGQUEST_BACKEND=modal) without
|
| 11 |
+
dragging in torch/diffusers/llama_cpp. The schemas live in schema.py (also dependency-free) and
|
| 12 |
+
are imported directly by both paths — they are NOT duplicated here.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
|
| 18 |
+
# ----------------------------- model ids / config (shared by both backends) -----------------------------
|
| 19 |
+
|
| 20 |
+
# Nemotron-3 Nano 4B GGUF (verified June 2026). Q8_0 (~4.3GB, near-fp16); filename is a glob that
|
| 21 |
+
# Llama.from_pretrained resolves to the exact file. (Hard floor Q4 — sub-4-bit degrades this arch.)
|
| 22 |
+
GGUF_REPO = "unsloth/NVIDIA-Nemotron-3-Nano-4B-GGUF"
|
| 23 |
+
GGUF_FILE = "*Q8_0*.gguf"
|
| 24 |
+
|
| 25 |
+
# FLUX.2 [klein] (verified: ungated, Apache 2.0). The "4B" is just the diffusion transformer; the
|
| 26 |
+
# repo also ships a large multimodal text encoder + VAE (~23GB total in bf16).
|
| 27 |
+
MODEL_ID = "black-forest-labs/FLUX.2-klein-4B"
|
| 28 |
+
|
| 29 |
+
# Image quality/speed knobs. klein is DISTILLED -> 4 steps is the model card's value (more doesn't
|
| 30 |
+
# help). 384 (a multiple of 16) matches the UI's ~380px display; resolution is the only real knob.
|
| 31 |
+
STEPS = 4
|
| 32 |
+
GUIDANCE = 4.0
|
| 33 |
+
MAX_SIDE = 384
|
| 34 |
+
|
| 35 |
+
# LLM context: full 128k on a big GPU; 16k on a small one (a 128k KV cache won't fit beside FLUX).
|
| 36 |
+
N_CTX = 131072
|
| 37 |
+
N_CTX_SMALL = 16384
|
| 38 |
+
LOW_VRAM_GB = 24 # at/below this, treat the GPU as "small" (T4 = 16GB, A10G/L4 = 24GB)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ----------------------------- LLM prompts -----------------------------
|
| 42 |
+
|
| 43 |
+
SYSTEM_PROMPT = """You are FrogQuest's quest designer. Convert the user's real to-do list into a themed text-adventure quest log and OUTPUT JSON ONLY - no prose, no markdown, no code.
|
| 44 |
+
|
| 45 |
+
Apply the "Eat That Frog" method:
|
| 46 |
+
- The FROG = the single most important/hardest task. Mark exactly ONE quest is_frog:true and order it FIRST.
|
| 47 |
+
- Break each big/multi-step goal into an ordered chain of smaller quests sharing one goal_group label; keep simple to-dos as standalone quests.
|
| 48 |
+
- Add 1-3 bonus self-care quests (type:"bonus") such as meditate 5 min, exercise 20 min, digital detox 1 hr. They are OPTIONAL and ENCOURAGING - never guilt-inducing.
|
| 49 |
+
|
| 50 |
+
For EVERY quest write vivid {theme}-themed, 8-bit pixel-art image instructions where the USER is the hero:
|
| 51 |
+
- initial_image_prompt: the hero facing the challenge (scene only - the renderer adds the user's face from a photo; do NOT describe their face).
|
| 52 |
+
- success_edit: edit instruction showing how the initial scene would look victorious.
|
| 53 |
+
- failure_edit: a FORGIVING edit instruction - the hero retreats to fight another day from the initial image. Never shaming.
|
| 54 |
+
|
| 55 |
+
Set adventure.art_style to one shared "8-bit / 16-bit pixel-art, {theme} palette" string applied to every image, and adventure.seed to a single integer for the whole adventure. xp 10-100 by effort. All status:"active", image_state:"initial". Echo the user's real wording in each quest.task.
|
| 56 |
+
/no_think"""
|
| 57 |
+
|
| 58 |
+
# Frog Master chat router. Classifies one user message into a single intent and OUTPUTS JSON ONLY.
|
| 59 |
+
INTENT_SYSTEM_PROMPT = """You are FrogQuest's "Frog Master" router. Read ONE user message plus a short context describing the current quest log, and classify it into EXACTLY ONE intent. OUTPUT JSON ONLY - no prose.
|
| 60 |
+
|
| 61 |
+
intent must be one of:
|
| 62 |
+
- "forge": the user is describing their to-do list / plans / goals for the first time (or wants a brand-new quest log). Use this when no quest log exists yet, or they clearly want to start over.
|
| 63 |
+
- "add_tasks": the user wants to ADD one or more new tasks/goals to the EXISTING quest log.
|
| 64 |
+
- "mark_done": the user says they FINISHED/completed a task. Put the task they mean in target_task (match it to one of the listed quest titles or tasks; leave empty to mean the currently selected quest).
|
| 65 |
+
- "mark_couldnt": the user could NOT do a task, or wants to skip/postpone it. Put the task in target_task (empty = currently selected quest) and put their explanation in reason.
|
| 66 |
+
- "unknown": small talk, a question, or anything that doesn't fit the above.
|
| 67 |
+
|
| 68 |
+
Only "forge" and "add_tasks" describe NEW work; if a log already exists and the user is describing more things to do, prefer "add_tasks". target_task should copy the matching quest's title or task wording when you can identify it.
|
| 69 |
+
/no_think"""
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ----------------------------- FLUX prompt builders -----------------------------
|
| 73 |
+
|
| 74 |
+
def build_initial_prompt(art_style: str, scene_prompt: str) -> str:
|
| 75 |
+
"""Initial generation: the user (from their reference photo) as the hero facing the scene."""
|
| 76 |
+
return (
|
| 77 |
+
f"{art_style}. {scene_prompt}. "
|
| 78 |
+
"The hero is the person shown in the reference image, in this style and scene."
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def build_edit_prompt(art_style: str, edit_instruction: str) -> str:
|
| 83 |
+
"""Edit pass: transform the existing scene into its success/failure state."""
|
| 84 |
+
return f"{art_style}. {edit_instruction}"
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ----------------------------- helpers -----------------------------
|
| 88 |
+
|
| 89 |
+
def extract_json(text: str) -> dict:
|
| 90 |
+
"""Parse JSON from model output, tolerating stray prose or code fences."""
|
| 91 |
+
text = (text or "").strip()
|
| 92 |
+
try:
|
| 93 |
+
return json.loads(text)
|
| 94 |
+
except json.JSONDecodeError:
|
| 95 |
+
pass
|
| 96 |
+
# Fallback: grab the outermost { ... } span.
|
| 97 |
+
start, end = text.find("{"), text.rfind("}")
|
| 98 |
+
if start != -1 and end != -1 and end > start:
|
| 99 |
+
try:
|
| 100 |
+
return json.loads(text[start : end + 1])
|
| 101 |
+
except json.JSONDecodeError:
|
| 102 |
+
pass
|
| 103 |
+
return {}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def preload_cuda_libs():
|
| 107 |
+
"""Load the CUDA runtime libs (libcudart.so.12, libcublas*, ...) RTLD_GLOBAL by full path so
|
| 108 |
+
the prebuilt llama.cpp CUDA wheel can resolve them. They ship in the nvidia-*-cu12 pip packages
|
| 109 |
+
and inside torch/lib, but neither is on the dynamic loader's search path. No-op for anything not
|
| 110 |
+
found. Order matters: cudart before cublasLt before cublas. Needed on BOTH ZeroGPU and Modal
|
| 111 |
+
(same prebuilt cu124 wheel, same missing-loader-path problem)."""
|
| 112 |
+
import ctypes
|
| 113 |
+
import glob
|
| 114 |
+
import os
|
| 115 |
+
import site
|
| 116 |
+
|
| 117 |
+
dirs = []
|
| 118 |
+
try:
|
| 119 |
+
import torch
|
| 120 |
+
dirs.append(os.path.join(os.path.dirname(torch.__file__), "lib"))
|
| 121 |
+
except Exception:
|
| 122 |
+
pass
|
| 123 |
+
site_dirs = []
|
| 124 |
+
if hasattr(site, "getsitepackages"):
|
| 125 |
+
site_dirs += site.getsitepackages()
|
| 126 |
+
site_dirs.append(os.path.dirname(os.path.dirname(os.__file__))) # fallback
|
| 127 |
+
for sp in dict.fromkeys(site_dirs):
|
| 128 |
+
dirs += glob.glob(os.path.join(sp, "nvidia", "*", "lib"))
|
| 129 |
+
|
| 130 |
+
for prefix in ("libcudart", "libnvrtc", "libcublasLt", "libcublas", "libcudnn"):
|
| 131 |
+
for d in dict.fromkeys(dirs):
|
| 132 |
+
for lib in sorted(glob.glob(os.path.join(d, prefix + "*.so*"))):
|
| 133 |
+
try:
|
| 134 |
+
ctypes.CDLL(lib, mode=ctypes.RTLD_GLOBAL)
|
| 135 |
+
except OSError:
|
| 136 |
+
pass
|
images.py
CHANGED
|
@@ -1,48 +1,51 @@
|
|
| 1 |
-
"""FLUX.2 [klein] 4B image pipeline
|
| 2 |
|
| 3 |
One model, one `image=` parameter does both jobs:
|
| 4 |
- initial generation: image=[user_photo] (a LIST -> multi-reference; puts the user in the scene)
|
| 5 |
- edit (next pass): image=base_image (a single image -> instruction-guided edit)
|
| 6 |
|
| 7 |
-
Shared art_style + a fixed per-adventure seed give every quest a cohesive look. Always EDIT
|
| 8 |
-
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
import base64
|
| 16 |
import io
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
import
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
#
|
| 30 |
-
|
| 31 |
-
STEPS = 4
|
| 32 |
-
GUIDANCE = 4.0
|
| 33 |
-
# Generated-scene resolution — the one real speed knob (time scales ~with pixel count).
|
| 34 |
-
# 384 is ~25% the work of 768 (≈4x faster) and matches the UI's ~380px display, so nothing
|
| 35 |
-
# visible is lost. Must stay a multiple of 16 (FLUX requirement) — that's why 384, not 380.
|
| 36 |
-
MAX_SIDE = 384
|
| 37 |
-
LOW_VRAM_GB = 24 # at/below this (e.g. T4 16GB) use fp16 + CPU offload so ~13GB of weights fit
|
| 38 |
|
| 39 |
# Best-effort: pre-fetch the weights at startup so the first @spaces.GPU call doesn't pay the
|
| 40 |
-
# multi-GB download out of its (metered, on ZeroGPU) duration.
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
| 46 |
|
| 47 |
_pipe = None
|
| 48 |
_offloaded = False # True when we used CPU offload (small GPU) instead of a full .to("cuda")
|
|
@@ -50,24 +53,28 @@ _offloaded = False # True when we used CPU offload (small GPU) instead of a ful
|
|
| 50 |
|
| 51 |
def _get_pipe():
|
| 52 |
"""Construct the pipeline lazily INSIDE the GPU call so we can read the REAL device's caps
|
| 53 |
-
and adapt —
|
| 54 |
-
|
| 55 |
-
hardware in the Space settings; no code change needed."""
|
| 56 |
global _pipe, _offloaded
|
| 57 |
if _pipe is None:
|
|
|
|
|
|
|
|
|
|
| 58 |
bf16 = torch.cuda.is_available() and torch.cuda.is_bf16_supported()
|
| 59 |
-
dtype = torch.bfloat16 if bf16 else torch.float16 #
|
| 60 |
_pipe = Flux2KleinPipeline.from_pretrained(MODEL_ID, torch_dtype=dtype)
|
| 61 |
vram_gb = (torch.cuda.get_device_properties(0).total_memory / 1e9
|
| 62 |
if torch.cuda.is_available() else 0)
|
| 63 |
if 0 < vram_gb < LOW_VRAM_GB:
|
| 64 |
-
# Small GPU: stream modules GPU<-CPU per step so the weights fit in ~
|
| 65 |
_pipe.enable_model_cpu_offload()
|
| 66 |
_offloaded = True
|
| 67 |
return _pipe
|
| 68 |
|
| 69 |
|
| 70 |
def _gen(prompt: str, image, seed: int) -> Image.Image:
|
|
|
|
|
|
|
| 71 |
pipe = _get_pipe()
|
| 72 |
if not _offloaded:
|
| 73 |
pipe.to("cuda") # full-residency path (big GPU); offload manages its own device moves
|
|
@@ -84,21 +91,39 @@ def _gen(prompt: str, image, seed: int) -> Image.Image:
|
|
| 84 |
return result.images[0]
|
| 85 |
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
|
|
|
| 89 |
"""Generate the quest's initial scene with the user as the hero (photo as reference)."""
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
| 102 |
|
| 103 |
|
| 104 |
# --- base64 <-> PIL helpers (photo arrives transiently as a data URL; nothing is persisted) ---
|
|
|
|
| 1 |
+
"""FLUX.2 [klein] 4B image pipeline. Pluggable GPU backend.
|
| 2 |
|
| 3 |
One model, one `image=` parameter does both jobs:
|
| 4 |
- initial generation: image=[user_photo] (a LIST -> multi-reference; puts the user in the scene)
|
| 5 |
- edit (next pass): image=base_image (a single image -> instruction-guided edit)
|
| 6 |
|
| 7 |
+
Shared art_style + a fixed per-adventure seed give every quest a cohesive look. Always EDIT the
|
| 8 |
+
initial image for success/failure states (consistency) - never text-to-image from scratch.
|
| 9 |
|
| 10 |
+
FROGQUEST_BACKEND selects WHERE inference runs (public functions identical either way):
|
| 11 |
+
- "zerogpu" (default): diffusers inside @spaces.GPU on the HF Space's ZeroGPU.
|
| 12 |
+
- "modal": forward to a deployed Modal class (see modal_app.py); the Space runs on CPU-basic.
|
| 13 |
+
|
| 14 |
+
IMPORTANT: torch / diffusers are imported LAZILY (inside _get_pipe/_gen), and the weight prefetch
|
| 15 |
+
is gated to the zerogpu backend, so importing this module on a CPU-basic Space (modal backend)
|
| 16 |
+
drags in nothing heavy and downloads nothing.
|
| 17 |
"""
|
| 18 |
from __future__ import annotations
|
| 19 |
|
| 20 |
import base64
|
| 21 |
import io
|
| 22 |
+
import os
|
| 23 |
+
|
| 24 |
+
from PIL import Image # light; used by the CPU-side data-url helpers and type hints
|
| 25 |
|
| 26 |
+
from gpu_shared import (
|
| 27 |
+
GUIDANCE,
|
| 28 |
+
LOW_VRAM_GB,
|
| 29 |
+
MAX_SIDE,
|
| 30 |
+
MODEL_ID,
|
| 31 |
+
STEPS,
|
| 32 |
+
build_edit_prompt,
|
| 33 |
+
build_initial_prompt,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
BACKEND = os.environ.get("FROGQUEST_BACKEND", "zerogpu").lower()
|
| 37 |
+
if BACKEND != "modal": # the local/ZeroGPU path (default + any unrecognized value) needs the decorator
|
| 38 |
+
import spaces
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
# Best-effort: pre-fetch the weights at startup so the first @spaces.GPU call doesn't pay the
|
| 41 |
+
# multi-GB download out of its (metered, on ZeroGPU) duration. ZeroGPU-only — a CPU-basic Space
|
| 42 |
+
# (modal backend) must NOT pull ~23GB. No-op offline / on a fresh local checkout.
|
| 43 |
+
if BACKEND != "modal":
|
| 44 |
+
try:
|
| 45 |
+
from huggingface_hub import snapshot_download
|
| 46 |
+
snapshot_download(MODEL_ID)
|
| 47 |
+
except Exception:
|
| 48 |
+
pass
|
| 49 |
|
| 50 |
_pipe = None
|
| 51 |
_offloaded = False # True when we used CPU offload (small GPU) instead of a full .to("cuda")
|
|
|
|
| 53 |
|
| 54 |
def _get_pipe():
|
| 55 |
"""Construct the pipeline lazily INSIDE the GPU call so we can read the REAL device's caps
|
| 56 |
+
and adapt — bf16 on Blackwell (ZeroGPU), fp16 on Turing, CPU offload when VRAM is tight. torch
|
| 57 |
+
and diffusers are imported here (not at module top) so the modal backend never loads them."""
|
|
|
|
| 58 |
global _pipe, _offloaded
|
| 59 |
if _pipe is None:
|
| 60 |
+
import torch
|
| 61 |
+
from diffusers import Flux2KleinPipeline
|
| 62 |
+
|
| 63 |
bf16 = torch.cuda.is_available() and torch.cuda.is_bf16_supported()
|
| 64 |
+
dtype = torch.bfloat16 if bf16 else torch.float16 # Turing (T4) has no bf16
|
| 65 |
_pipe = Flux2KleinPipeline.from_pretrained(MODEL_ID, torch_dtype=dtype)
|
| 66 |
vram_gb = (torch.cuda.get_device_properties(0).total_memory / 1e9
|
| 67 |
if torch.cuda.is_available() else 0)
|
| 68 |
if 0 < vram_gb < LOW_VRAM_GB:
|
| 69 |
+
# Small GPU: stream modules GPU<-CPU per step so the ~23GB of weights fit in ~24GB VRAM.
|
| 70 |
_pipe.enable_model_cpu_offload()
|
| 71 |
_offloaded = True
|
| 72 |
return _pipe
|
| 73 |
|
| 74 |
|
| 75 |
def _gen(prompt: str, image, seed: int) -> Image.Image:
|
| 76 |
+
import torch
|
| 77 |
+
|
| 78 |
pipe = _get_pipe()
|
| 79 |
if not _offloaded:
|
| 80 |
pipe.to("cuda") # full-residency path (big GPU); offload manages its own device moves
|
|
|
|
| 91 |
return result.images[0]
|
| 92 |
|
| 93 |
|
| 94 |
+
# ----------------------------- local (in-Space, ZeroGPU) implementations -----------------------------
|
| 95 |
+
|
| 96 |
+
def _initial_image_local(user_photo: Image.Image, art_style: str, scene_prompt: str, seed: int) -> Image.Image:
|
| 97 |
"""Generate the quest's initial scene with the user as the hero (photo as reference)."""
|
| 98 |
+
return _gen(build_initial_prompt(art_style, scene_prompt), image=[user_photo], seed=seed)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _edit_image_local(base_image: Image.Image, edit_instruction: str, art_style: str, seed: int) -> Image.Image:
|
| 102 |
+
"""Edit the existing image into a success/failure state."""
|
| 103 |
+
return _gen(build_edit_prompt(art_style, edit_instruction), image=base_image, seed=seed)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ----------------------------- modal (off-Space) wrappers -----------------------------
|
| 107 |
+
|
| 108 |
+
def _initial_image_modal(user_photo: Image.Image, art_style: str, scene_prompt: str, seed: int) -> Image.Image:
|
| 109 |
+
import modal
|
| 110 |
+
flux = modal.Cls.from_name("frogquest", "Flux")()
|
| 111 |
+
return flux.initial.remote(user_photo, art_style, scene_prompt, seed)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _edit_image_modal(base_image: Image.Image, edit_instruction: str, art_style: str, seed: int) -> Image.Image:
|
| 115 |
+
import modal
|
| 116 |
+
flux = modal.Cls.from_name("frogquest", "Flux")()
|
| 117 |
+
return flux.edit.remote(base_image, edit_instruction, art_style, seed)
|
| 118 |
|
| 119 |
|
| 120 |
+
# ----------------------------- bind public names from the backend -----------------------------
|
| 121 |
+
if BACKEND == "modal":
|
| 122 |
+
initial_image = _initial_image_modal
|
| 123 |
+
edit_image = _edit_image_modal
|
| 124 |
+
else:
|
| 125 |
+
initial_image = spaces.GPU(duration=30)(_initial_image_local)
|
| 126 |
+
edit_image = spaces.GPU(duration=30)(_edit_image_local)
|
| 127 |
|
| 128 |
|
| 129 |
# --- base64 <-> PIL helpers (photo arrives transiently as a data URL; nothing is persisted) ---
|
llm.py
CHANGED
|
@@ -1,117 +1,57 @@
|
|
| 1 |
-
"""Nemotron Nano 4B (text-only) -> raw quest JSON
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
The LLM's job is ONLY to write JSON to the contract in schema.py. Output is constrained
|
| 8 |
-
|
|
|
|
| 9 |
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
-
import json
|
| 13 |
import os
|
| 14 |
|
| 15 |
os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1") # MUST precede huggingface_hub import
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
| 18 |
|
| 19 |
from schema import INTENT_SCHEMA, RESPONSE_SCHEMA # noqa: E402
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
# 16GB) a 128k KV cache won't fit alongside FLUX, so we drop to 16k — still vastly more than this
|
| 32 |
-
# app's tiny prompts ever need. This is one half of the ZeroGPU<->T4 portability (see images.py).
|
| 33 |
-
N_CTX = 131072 # big GPUs (ZeroGPU)
|
| 34 |
-
N_CTX_SMALL = 16384 # small GPUs (T4 & similar)
|
| 35 |
-
LOW_VRAM_GB = 24 # at/below this, treat the GPU as "small" (T4 = 16GB)
|
| 36 |
|
| 37 |
# Best-effort: warm the HF cache at startup so the FIRST @spaces.GPU call doesn't spend its
|
| 38 |
-
# (metered, on ZeroGPU) duration downloading ~4GB.
|
| 39 |
-
#
|
| 40 |
-
|
| 41 |
-
from huggingface_hub import hf_hub_download, list_repo_files # noqa: E402
|
| 42 |
-
_gguf = next((f for f in list_repo_files(GGUF_REPO) if "Q8_0" in f and f.endswith(".gguf")), None)
|
| 43 |
-
if _gguf:
|
| 44 |
-
hf_hub_download(GGUF_REPO, _gguf)
|
| 45 |
-
except Exception:
|
| 46 |
-
pass
|
| 47 |
-
|
| 48 |
-
SYSTEM_PROMPT = """You are FrogQuest's quest designer. Convert the user's real to-do list into a themed text-adventure quest log and OUTPUT JSON ONLY - no prose, no markdown, no code.
|
| 49 |
-
|
| 50 |
-
Apply the "Eat That Frog" method:
|
| 51 |
-
- The FROG = the single most important/hardest task. Mark exactly ONE quest is_frog:true and order it FIRST.
|
| 52 |
-
- Break each big/multi-step goal into an ordered chain of smaller quests sharing one goal_group label; keep simple to-dos as standalone quests.
|
| 53 |
-
- Add 1-3 bonus self-care quests (type:"bonus") such as meditate 5 min, exercise 20 min, digital detox 1 hr. They are OPTIONAL and ENCOURAGING - never guilt-inducing.
|
| 54 |
-
|
| 55 |
-
For EVERY quest write vivid {theme}-themed, 8-bit pixel-art image instructions where the USER is the hero:
|
| 56 |
-
- initial_image_prompt: the hero facing the challenge (scene only - the renderer adds the user's face from a photo; do NOT describe their face).
|
| 57 |
-
- success_edit: edit instruction showing how the initial scene would look victorious.
|
| 58 |
-
- failure_edit: a FORGIVING edit instruction - the hero retreats to fight another day from the initial image. Never shaming.
|
| 59 |
-
|
| 60 |
-
Set adventure.art_style to one shared "8-bit / 16-bit pixel-art, {theme} palette" string applied to every image, and adventure.seed to a single integer for the whole adventure. xp 10-100 by effort. All status:"active", image_state:"initial". Echo the user's real wording in each quest.task.
|
| 61 |
-
/no_think"""
|
| 62 |
-
|
| 63 |
-
# Frog Master chat router. Classifies one user message into a single intent and OUTPUTS JSON ONLY.
|
| 64 |
-
INTENT_SYSTEM_PROMPT = """You are FrogQuest's "Frog Master" router. Read ONE user message plus a short context describing the current quest log, and classify it into EXACTLY ONE intent. OUTPUT JSON ONLY - no prose.
|
| 65 |
-
|
| 66 |
-
intent must be one of:
|
| 67 |
-
- "forge": the user is describing their to-do list / plans / goals for the first time (or wants a brand-new quest log). Use this when no quest log exists yet, or they clearly want to start over.
|
| 68 |
-
- "add_tasks": the user wants to ADD one or more new tasks/goals to the EXISTING quest log.
|
| 69 |
-
- "mark_done": the user says they FINISHED/completed a task. Put the task they mean in target_task (match it to one of the listed quest titles or tasks; leave empty to mean the currently selected quest).
|
| 70 |
-
- "mark_couldnt": the user could NOT do a task, or wants to skip/postpone it. Put the task in target_task (empty = currently selected quest) and put their explanation in reason.
|
| 71 |
-
- "unknown": small talk, a question, or anything that doesn't fit the above.
|
| 72 |
-
|
| 73 |
-
Only "forge" and "add_tasks" describe NEW work; if a log already exists and the user is describing more things to do, prefer "add_tasks". target_task should copy the matching quest's title or task wording when you can identify it.
|
| 74 |
-
/no_think"""
|
| 75 |
-
|
| 76 |
-
_llm = None
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def _preload_cuda_libs():
|
| 80 |
-
"""Load the CUDA runtime libs (libcudart.so.12, libcublas*, ...) RTLD_GLOBAL by full path
|
| 81 |
-
so the prebuilt llama.cpp CUDA wheel can resolve them. They ship in the nvidia-*-cu12 pip
|
| 82 |
-
packages and inside torch/lib, but neither is on the dynamic loader's search path. No-op
|
| 83 |
-
for anything not found. Order matters: cudart before cublasLt before cublas."""
|
| 84 |
-
import ctypes
|
| 85 |
-
import glob
|
| 86 |
-
import os
|
| 87 |
-
import site
|
| 88 |
-
|
| 89 |
-
dirs = []
|
| 90 |
try:
|
| 91 |
-
import
|
| 92 |
-
|
|
|
|
|
|
|
| 93 |
except Exception:
|
| 94 |
pass
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
site_dirs += site.getsitepackages()
|
| 98 |
-
site_dirs.append(os.path.dirname(os.path.dirname(os.__file__))) # fallback
|
| 99 |
-
for sp in dict.fromkeys(site_dirs):
|
| 100 |
-
dirs += glob.glob(os.path.join(sp, "nvidia", "*", "lib"))
|
| 101 |
-
|
| 102 |
-
for prefix in ("libcudart", "libnvrtc", "libcublasLt", "libcublas", "libcudnn"):
|
| 103 |
-
for d in dict.fromkeys(dirs):
|
| 104 |
-
for lib in sorted(glob.glob(os.path.join(d, prefix + "*.so*"))):
|
| 105 |
-
try:
|
| 106 |
-
ctypes.CDLL(lib, mode=ctypes.RTLD_GLOBAL)
|
| 107 |
-
except OSError:
|
| 108 |
-
pass
|
| 109 |
|
| 110 |
|
| 111 |
def _get_llm():
|
| 112 |
"""Lazily download + construct the Llama model on the GPU (must run inside @spaces.GPU).
|
| 113 |
|
| 114 |
-
First call downloads the GGUF
|
| 115 |
"""
|
| 116 |
global _llm
|
| 117 |
if _llm is None:
|
|
@@ -121,7 +61,7 @@ def _get_llm():
|
|
| 121 |
# 1) importing torch loads many of them RTLD_GLOBAL;
|
| 122 |
# 2) belt-and-suspenders: explicitly preload the nvidia-* CUDA libs too.
|
| 123 |
import torch # noqa: F401
|
| 124 |
-
|
| 125 |
from llama_cpp import Llama
|
| 126 |
|
| 127 |
vram_gb = (torch.cuda.get_device_properties(0).total_memory / 1e9
|
|
@@ -137,8 +77,9 @@ def _get_llm():
|
|
| 137 |
return _llm
|
| 138 |
|
| 139 |
|
| 140 |
-
|
| 141 |
-
|
|
|
|
| 142 |
"""Return the model's raw JSON object (UNVALIDATED - caller must validate_and_clamp)."""
|
| 143 |
llm = _get_llm()
|
| 144 |
system = SYSTEM_PROMPT.replace("{theme}", theme)
|
|
@@ -153,17 +94,14 @@ def generate_quests_raw(todos: str, theme: str) -> dict:
|
|
| 153 |
temperature=0.0,
|
| 154 |
max_tokens=4096,
|
| 155 |
)
|
| 156 |
-
|
| 157 |
-
return _extract_json(content)
|
| 158 |
|
| 159 |
|
| 160 |
-
|
| 161 |
-
def route_intent(message: str, context: str) -> dict:
|
| 162 |
"""Classify one Frog Master chat message into {intent, target_task?, reason?}.
|
| 163 |
|
| 164 |
`context` is a SHORT text summary of the current log (does a log exist + quest titles/ids/
|
| 165 |
-
status) - never images (CLAUDE.md rule).
|
| 166 |
-
what to do. Falls back to {"intent": "unknown"} on unparseable output.
|
| 167 |
"""
|
| 168 |
llm = _get_llm()
|
| 169 |
user = f"Context:\n{context.strip()}\n\nUser message:\n{message.strip()}"
|
|
@@ -176,7 +114,7 @@ def route_intent(message: str, context: str) -> dict:
|
|
| 176 |
temperature=0.0,
|
| 177 |
max_tokens=256,
|
| 178 |
)
|
| 179 |
-
parsed =
|
| 180 |
if not isinstance(parsed, dict) or parsed.get("intent") not in (
|
| 181 |
"forge", "add_tasks", "mark_done", "mark_couldnt", "unknown",
|
| 182 |
):
|
|
@@ -184,18 +122,25 @@ def route_intent(message: str, context: str) -> dict:
|
|
| 184 |
return parsed
|
| 185 |
|
| 186 |
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Nemotron Nano 4B (text-only) -> raw quest JSON. Pluggable GPU backend.
|
| 2 |
|
| 3 |
+
FROGQUEST_BACKEND selects WHERE the GPU work runs (the public functions are identical either way):
|
| 4 |
+
- "zerogpu" (default): construct the Llama via llama.cpp INSIDE a @spaces.GPU function on the
|
| 5 |
+
HF Space's ZeroGPU. (First call ~60-90s, then disk-cached & fast.)
|
| 6 |
+
- "modal": forward to a deployed Modal class (see modal_app.py); the Space itself runs on
|
| 7 |
+
CPU-basic and imports NOTHING heavy here.
|
| 8 |
|
| 9 |
+
The LLM's job is ONLY to write JSON to the contract in schema.py. Output is constrained with a
|
| 10 |
+
JSON-schema response_format and then validated/clamped by the caller. Shared prompts / the JSON
|
| 11 |
+
extractor / model config live in gpu_shared.py so both backends stay in lockstep.
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
| 14 |
|
|
|
|
| 15 |
import os
|
| 16 |
|
| 17 |
os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1") # MUST precede huggingface_hub import
|
| 18 |
|
| 19 |
+
BACKEND = os.environ.get("FROGQUEST_BACKEND", "zerogpu").lower()
|
| 20 |
+
if BACKEND != "modal": # the local/ZeroGPU path (default + any unrecognized value) needs the decorator
|
| 21 |
+
import spaces # noqa: E402
|
| 22 |
|
| 23 |
from schema import INTENT_SCHEMA, RESPONSE_SCHEMA # noqa: E402
|
| 24 |
+
from gpu_shared import ( # noqa: E402
|
| 25 |
+
GGUF_FILE,
|
| 26 |
+
GGUF_REPO,
|
| 27 |
+
INTENT_SYSTEM_PROMPT,
|
| 28 |
+
LOW_VRAM_GB,
|
| 29 |
+
N_CTX,
|
| 30 |
+
N_CTX_SMALL,
|
| 31 |
+
SYSTEM_PROMPT,
|
| 32 |
+
extract_json,
|
| 33 |
+
preload_cuda_libs,
|
| 34 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
# Best-effort: warm the HF cache at startup so the FIRST @spaces.GPU call doesn't spend its
|
| 37 |
+
# (metered, on ZeroGPU) duration downloading ~4GB. Local-path only — on a CPU-basic Space (modal
|
| 38 |
+
# backend) we must NOT download the GGUF. No-op if offline or on a fresh local checkout.
|
| 39 |
+
if BACKEND != "modal":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
try:
|
| 41 |
+
from huggingface_hub import hf_hub_download, list_repo_files
|
| 42 |
+
_gguf = next((f for f in list_repo_files(GGUF_REPO) if "Q8_0" in f and f.endswith(".gguf")), None)
|
| 43 |
+
if _gguf:
|
| 44 |
+
hf_hub_download(GGUF_REPO, _gguf)
|
| 45 |
except Exception:
|
| 46 |
pass
|
| 47 |
+
|
| 48 |
+
_llm = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
def _get_llm():
|
| 52 |
"""Lazily download + construct the Llama model on the GPU (must run inside @spaces.GPU).
|
| 53 |
|
| 54 |
+
First call downloads the GGUF then disk-caches it, so later calls are fast.
|
| 55 |
"""
|
| 56 |
global _llm
|
| 57 |
if _llm is None:
|
|
|
|
| 61 |
# 1) importing torch loads many of them RTLD_GLOBAL;
|
| 62 |
# 2) belt-and-suspenders: explicitly preload the nvidia-* CUDA libs too.
|
| 63 |
import torch # noqa: F401
|
| 64 |
+
preload_cuda_libs()
|
| 65 |
from llama_cpp import Llama
|
| 66 |
|
| 67 |
vram_gb = (torch.cuda.get_device_properties(0).total_memory / 1e9
|
|
|
|
| 77 |
return _llm
|
| 78 |
|
| 79 |
|
| 80 |
+
# ----------------------------- local (in-Space, ZeroGPU) implementations -----------------------------
|
| 81 |
+
|
| 82 |
+
def _generate_quests_local(todos: str, theme: str) -> dict:
|
| 83 |
"""Return the model's raw JSON object (UNVALIDATED - caller must validate_and_clamp)."""
|
| 84 |
llm = _get_llm()
|
| 85 |
system = SYSTEM_PROMPT.replace("{theme}", theme)
|
|
|
|
| 94 |
temperature=0.0,
|
| 95 |
max_tokens=4096,
|
| 96 |
)
|
| 97 |
+
return extract_json(out["choices"][0]["message"]["content"])
|
|
|
|
| 98 |
|
| 99 |
|
| 100 |
+
def _route_intent_local(message: str, context: str) -> dict:
|
|
|
|
| 101 |
"""Classify one Frog Master chat message into {intent, target_task?, reason?}.
|
| 102 |
|
| 103 |
`context` is a SHORT text summary of the current log (does a log exist + quest titles/ids/
|
| 104 |
+
status) - never images (CLAUDE.md rule). Falls back to {"intent": "unknown"} on bad output.
|
|
|
|
| 105 |
"""
|
| 106 |
llm = _get_llm()
|
| 107 |
user = f"Context:\n{context.strip()}\n\nUser message:\n{message.strip()}"
|
|
|
|
| 114 |
temperature=0.0,
|
| 115 |
max_tokens=256,
|
| 116 |
)
|
| 117 |
+
parsed = extract_json(out["choices"][0]["message"]["content"])
|
| 118 |
if not isinstance(parsed, dict) or parsed.get("intent") not in (
|
| 119 |
"forge", "add_tasks", "mark_done", "mark_couldnt", "unknown",
|
| 120 |
):
|
|
|
|
| 122 |
return parsed
|
| 123 |
|
| 124 |
|
| 125 |
+
# ----------------------------- modal (off-Space) wrappers -----------------------------
|
| 126 |
+
|
| 127 |
+
def _generate_quests_modal(todos: str, theme: str) -> dict:
|
| 128 |
+
import modal
|
| 129 |
+
llm = modal.Cls.from_name("frogquest", "LLM")()
|
| 130 |
+
return llm.generate_quests.remote(todos, theme)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _route_intent_modal(message: str, context: str) -> dict:
|
| 134 |
+
import modal
|
| 135 |
+
llm = modal.Cls.from_name("frogquest", "LLM")()
|
| 136 |
+
return llm.route_intent.remote(message, context)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ----------------------------- bind public names from the backend -----------------------------
|
| 140 |
+
# app.py imports these by name; signatures are identical across backends.
|
| 141 |
+
if BACKEND == "modal":
|
| 142 |
+
generate_quests_raw = _generate_quests_modal
|
| 143 |
+
route_intent = _route_intent_modal
|
| 144 |
+
else:
|
| 145 |
+
generate_quests_raw = spaces.GPU(duration=70)(_generate_quests_local)
|
| 146 |
+
route_intent = spaces.GPU(duration=45)(_route_intent_local)
|
modal_app.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Modal backend for FrogQuest's GPU work — the off-Space alternative to ZeroGPU.
|
| 2 |
+
|
| 3 |
+
Deploy once: `modal deploy modal_app.py`
|
| 4 |
+
The HF Space (running on CPU-basic with FROGQUEST_BACKEND=modal) then invokes these via
|
| 5 |
+
`modal.Cls.from_name("frogquest", "LLM" | "Flux")().<method>.remote(...)` from llm.py / images.py.
|
| 6 |
+
|
| 7 |
+
Both backends share the SAME prompts, config, JSON extractor (gpu_shared) and JSON schemas
|
| 8 |
+
(schema) so they can never drift. Those two local files are mounted into the image with
|
| 9 |
+
`add_local_python_source`.
|
| 10 |
+
|
| 11 |
+
GPU config:
|
| 12 |
+
- LLM (Nemotron Q8 ~4.3GB): a cheap L4 is plenty.
|
| 13 |
+
- FLUX.2 klein (~23GB): set FROGQUEST_MODAL_FLUX_GPU at DEPLOY time (default "A10G", 24GB ->
|
| 14 |
+
needs enable_model_cpu_offload(); use "L4" to save cost or "L40S"/"A100-40GB" if it OOMs).
|
| 15 |
+
|
| 16 |
+
NOTE: re-verify Modal API specifics against current docs (Modal 1.0+): App / Image.pip_install
|
| 17 |
+
extra_index_url / add_local_python_source / @app.cls / @modal.enter / @modal.method /
|
| 18 |
+
Cls.from_name / Volume.commit.
|
| 19 |
+
"""
|
| 20 |
+
import os
|
| 21 |
+
|
| 22 |
+
import modal
|
| 23 |
+
|
| 24 |
+
from gpu_shared import (
|
| 25 |
+
GGUF_FILE,
|
| 26 |
+
GGUF_REPO,
|
| 27 |
+
GUIDANCE,
|
| 28 |
+
INTENT_SYSTEM_PROMPT,
|
| 29 |
+
MAX_SIDE,
|
| 30 |
+
MODEL_ID,
|
| 31 |
+
N_CTX,
|
| 32 |
+
STEPS,
|
| 33 |
+
SYSTEM_PROMPT,
|
| 34 |
+
build_edit_prompt,
|
| 35 |
+
build_initial_prompt,
|
| 36 |
+
extract_json,
|
| 37 |
+
preload_cuda_libs,
|
| 38 |
+
)
|
| 39 |
+
from schema import INTENT_SCHEMA, RESPONSE_SCHEMA
|
| 40 |
+
|
| 41 |
+
app = modal.App("frogquest")
|
| 42 |
+
|
| 43 |
+
# Persist the HF weight cache (FLUX ~23GB + GGUF ~4.3GB) across cold starts -> download once.
|
| 44 |
+
CACHE_DIR = "/cache"
|
| 45 |
+
vol = modal.Volume.from_name("frogquest-cache", create_if_missing=True)
|
| 46 |
+
|
| 47 |
+
# Prebuilt CUDA wheel for llama.cpp (same index the Space uses). cu124 wheels run on Modal GPUs.
|
| 48 |
+
_LLAMA_INDEX = "https://abetlen.github.io/llama-cpp-python/whl/cu124"
|
| 49 |
+
|
| 50 |
+
image = (
|
| 51 |
+
modal.Image.debian_slim(python_version="3.12")
|
| 52 |
+
.pip_install(
|
| 53 |
+
"git+https://github.com/huggingface/diffusers.git", # Flux2KleinPipeline (>=0.38)
|
| 54 |
+
"transformers>=4.44.2",
|
| 55 |
+
"accelerate", # required by enable_model_cpu_offload()
|
| 56 |
+
"torch",
|
| 57 |
+
"pillow",
|
| 58 |
+
"sentencepiece", # FLUX.2 text-encoder tokenizer deps (belt-and-suspenders)
|
| 59 |
+
"protobuf",
|
| 60 |
+
"huggingface-hub",
|
| 61 |
+
"hf-transfer",
|
| 62 |
+
)
|
| 63 |
+
.pip_install(
|
| 64 |
+
"llama-cpp-python==0.3.28",
|
| 65 |
+
extra_index_url=_LLAMA_INDEX,
|
| 66 |
+
extra_options="--prefer-binary",
|
| 67 |
+
)
|
| 68 |
+
.env({"HF_HOME": CACHE_DIR, "HF_HUB_ENABLE_HF_TRANSFER": "1"})
|
| 69 |
+
.add_local_python_source("schema", "gpu_shared")
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# Configurable at deploy time (read on your machine when you run `modal deploy`).
|
| 73 |
+
FLUX_GPU = os.environ.get("FROGQUEST_MODAL_FLUX_GPU", "A10G")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@app.cls(gpu="L4", image=image, volumes={CACHE_DIR: vol}, scaledown_window=300)
|
| 77 |
+
class LLM:
|
| 78 |
+
@modal.enter()
|
| 79 |
+
def _load(self):
|
| 80 |
+
vol.reload() # pick up weights another container may have already cached
|
| 81 |
+
import torch # noqa: F401 (loads CUDA libs RTLD_GLOBAL)
|
| 82 |
+
preload_cuda_libs()
|
| 83 |
+
from llama_cpp import Llama
|
| 84 |
+
|
| 85 |
+
self.llm = Llama.from_pretrained(
|
| 86 |
+
repo_id=GGUF_REPO,
|
| 87 |
+
filename=GGUF_FILE,
|
| 88 |
+
n_gpu_layers=-1,
|
| 89 |
+
n_ctx=N_CTX, # Modal GPU has the VRAM for the full 128k
|
| 90 |
+
verbose=False,
|
| 91 |
+
)
|
| 92 |
+
vol.commit() # persist the freshly downloaded GGUF (no-op if already cached)
|
| 93 |
+
|
| 94 |
+
@modal.method()
|
| 95 |
+
def generate_quests(self, todos: str, theme: str) -> dict:
|
| 96 |
+
system = SYSTEM_PROMPT.replace("{theme}", theme)
|
| 97 |
+
user = f"Theme: {theme}\nMy to-do list / goals:\n{todos.strip()}"
|
| 98 |
+
out = self.llm.create_chat_completion(
|
| 99 |
+
messages=[
|
| 100 |
+
{"role": "system", "content": system},
|
| 101 |
+
{"role": "user", "content": user},
|
| 102 |
+
],
|
| 103 |
+
response_format={"type": "json_object", "schema": RESPONSE_SCHEMA},
|
| 104 |
+
temperature=0.0,
|
| 105 |
+
max_tokens=4096,
|
| 106 |
+
)
|
| 107 |
+
return extract_json(out["choices"][0]["message"]["content"])
|
| 108 |
+
|
| 109 |
+
@modal.method()
|
| 110 |
+
def route_intent(self, message: str, context: str) -> dict:
|
| 111 |
+
user = f"Context:\n{context.strip()}\n\nUser message:\n{message.strip()}"
|
| 112 |
+
out = self.llm.create_chat_completion(
|
| 113 |
+
messages=[
|
| 114 |
+
{"role": "system", "content": INTENT_SYSTEM_PROMPT},
|
| 115 |
+
{"role": "user", "content": user},
|
| 116 |
+
],
|
| 117 |
+
response_format={"type": "json_object", "schema": INTENT_SCHEMA},
|
| 118 |
+
temperature=0.0,
|
| 119 |
+
max_tokens=256,
|
| 120 |
+
)
|
| 121 |
+
parsed = extract_json(out["choices"][0]["message"]["content"])
|
| 122 |
+
if not isinstance(parsed, dict) or parsed.get("intent") not in (
|
| 123 |
+
"forge", "add_tasks", "mark_done", "mark_couldnt", "unknown",
|
| 124 |
+
):
|
| 125 |
+
return {"intent": "unknown"}
|
| 126 |
+
return parsed
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@app.cls(gpu=FLUX_GPU, image=image, volumes={CACHE_DIR: vol}, scaledown_window=300)
|
| 130 |
+
class Flux:
|
| 131 |
+
@modal.enter()
|
| 132 |
+
def _load(self):
|
| 133 |
+
vol.reload()
|
| 134 |
+
import torch
|
| 135 |
+
from diffusers import Flux2KleinPipeline
|
| 136 |
+
|
| 137 |
+
self._torch = torch
|
| 138 |
+
pipe = Flux2KleinPipeline.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16)
|
| 139 |
+
# 24GB card vs ~23GB model: offload to fit with headroom for activations.
|
| 140 |
+
pipe.enable_model_cpu_offload()
|
| 141 |
+
self.pipe = pipe
|
| 142 |
+
vol.commit() # persist the freshly downloaded FLUX weights (no-op if already cached)
|
| 143 |
+
|
| 144 |
+
def _gen(self, prompt, image, seed):
|
| 145 |
+
gen = self._torch.Generator("cuda").manual_seed(int(seed))
|
| 146 |
+
result = self.pipe(
|
| 147 |
+
prompt=prompt,
|
| 148 |
+
image=image,
|
| 149 |
+
generator=gen,
|
| 150 |
+
num_inference_steps=STEPS,
|
| 151 |
+
guidance_scale=GUIDANCE,
|
| 152 |
+
height=MAX_SIDE,
|
| 153 |
+
width=MAX_SIDE,
|
| 154 |
+
)
|
| 155 |
+
return result.images[0]
|
| 156 |
+
|
| 157 |
+
@modal.method()
|
| 158 |
+
def initial(self, user_photo, art_style: str, scene_prompt: str, seed: int):
|
| 159 |
+
return self._gen(build_initial_prompt(art_style, scene_prompt), [user_photo], seed)
|
| 160 |
+
|
| 161 |
+
@modal.method()
|
| 162 |
+
def edit(self, base_image, edit_instruction: str, art_style: str, seed: int):
|
| 163 |
+
return self._gen(build_edit_prompt(art_style, edit_instruction), base_image, seed)
|
requirements-modal.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OPTIONAL lean dependency set for running the HF Space in MODAL mode (FROGQUEST_BACKEND=modal).
|
| 2 |
+
#
|
| 3 |
+
# In modal mode the Space runs on CPU-basic and offloads ALL GPU work to Modal, so it never
|
| 4 |
+
# imports torch / diffusers / llama-cpp. Swapping requirements.txt for this file (rename it to
|
| 5 |
+
# requirements.txt on the Space) skips installing those heavy libs -> much faster Space builds.
|
| 6 |
+
# The default requirements.txt keeps both sets so a single push works in either mode.
|
| 7 |
+
gradio>=5.0
|
| 8 |
+
spaces
|
| 9 |
+
pillow
|
| 10 |
+
huggingface-hub
|
| 11 |
+
modal
|
requirements.txt
CHANGED
|
@@ -16,6 +16,9 @@ nvidia-cuda-nvrtc-cu12
|
|
| 16 |
pillow
|
| 17 |
hf-transfer
|
| 18 |
huggingface-hub
|
|
|
|
|
|
|
|
|
|
| 19 |
# llama-cpp-python: install the PREBUILT CUDA wheel, never compile from source.
|
| 20 |
# The cu128 index does not exist (404) -> pip silently falls back to a 30+ min source build.
|
| 21 |
# cu124 is the proven index (used by the reference ZeroGPU Space) and its wheels run fine on
|
|
|
|
| 16 |
pillow
|
| 17 |
hf-transfer
|
| 18 |
huggingface-hub
|
| 19 |
+
# Modal client — only used when FROGQUEST_BACKEND=modal (the Space forwards GPU work to Modal).
|
| 20 |
+
# Harmless in the default zerogpu mode (imported lazily inside the wrapper functions only).
|
| 21 |
+
modal
|
| 22 |
# llama-cpp-python: install the PREBUILT CUDA wheel, never compile from source.
|
| 23 |
# The cu128 index does not exist (404) -> pip silently falls back to a 30+ min source build.
|
| 24 |
# cu124 is the proven index (used by the reference ZeroGPU Space) and its wheels run fine on
|