VirusDumb commited on
Commit
dfb8f25
·
1 Parent(s): 5dfec61

GPU hack modal

Browse files
Files changed (7) hide show
  1. README.md +25 -2
  2. gpu_shared.py +136 -0
  3. images.py +73 -48
  4. llm.py +63 -118
  5. modal_app.py +163 -0
  6. requirements-modal.txt +11 -0
  7. 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
- **Set the Space hardware to ZeroGPU.** First quest generation cold-downloads the model GGUF
33
- (~60-90s), then runs fast.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (diffusers, ZeroGPU).
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
- the initial image for success/failure states (consistency) - never text-to-image from scratch.
9
 
10
- Pipeline loaded at module level; CUDA emulation makes that safe outside @spaces.GPU. The
11
- actual .to("cuda") + inference happen inside the GPU-decorated functions.
 
 
 
 
 
12
  """
13
  from __future__ import annotations
14
 
15
  import base64
16
  import io
 
 
 
17
 
18
- import spaces
19
- import torch
20
- from diffusers import Flux2KleinPipeline
21
- from PIL import Image
22
-
23
- MODEL_ID = "black-forest-labs/FLUX.2-klein-4B" # verified: ungated, Apache 2.0
24
- # Quality config:
25
- # - precision: bf16 on GPUs that support it (ZeroGPU/Blackwell = klein's NATIVE dtype, best
26
- # quality). On a Turing GPU like T4 (no bf16) we fall back to fp16 — see _get_pipe.
27
- # - steps: 4 is the model card's recommended value because klein is DISTILLED; raising it
28
- # does not improve a distilled model (and can hurt).
29
- # - there is no "context length" for a diffusion model.
30
- # - resolution is the only real speed/quality knob; 768 keeps generation fast.
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. No-op offline / on fresh checkout.
41
- try:
42
- from huggingface_hub import snapshot_download
43
- snapshot_download(MODEL_ID)
44
- except Exception:
45
- pass
 
 
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 — this is what lets ONE codebase run on both ZeroGPU (Blackwell, 48GB, native
54
- bf16) and a small standard GPU like T4 (Turing, 16GB, fp16 + CPU offload). Just flip the
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 # T4 (Turing) has no bf16
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 ~16GB VRAM.
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
- @spaces.GPU(duration=30)
88
- def initial_image(user_photo: Image.Image, art_style: str, scene_prompt: str, seed: int) -> Image.Image:
 
89
  """Generate the quest's initial scene with the user as the hero (photo as reference)."""
90
- prompt = (
91
- f"{art_style}. {scene_prompt}. "
92
- "The hero is the person shown in the reference image, in this style and scene."
93
- )
94
- return _gen(prompt, image=[user_photo], seed=seed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
 
97
- @spaces.GPU(duration=30)
98
- def edit_image(base_image: Image.Image, edit_instruction: str, art_style: str, seed: int) -> Image.Image:
99
- """NEXT PASS (priority 5): edit the existing image into a success/failure state."""
100
- prompt = f"{art_style}. {edit_instruction}"
101
- return _gen(prompt, image=base_image, seed=seed)
 
 
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, run via llama.cpp on ZeroGPU.
2
 
3
- Recipe (CLAUDE.md + ZeroGPU/llama.cpp reference): set HF_HUB_ENABLE_HF_TRANSFER before
4
- importing huggingface_hub, download the GGUF at module level, but construct the Llama
5
- object INSIDE the @spaces.GPU function (first call ~60-90s, then disk-cached & fast).
 
 
6
 
7
- The LLM's job is ONLY to write JSON to the contract in schema.py. Output is constrained
8
- with a JSON-schema response_format and then validated/clamped by the caller.
 
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
- import spaces # noqa: E402
 
 
18
 
19
  from schema import INTENT_SCHEMA, RESPONSE_SCHEMA # noqa: E402
20
-
21
- # Verified on HF (June 2026). unsloth's GGUF of NVIDIA Nemotron-3 Nano 4B (the repo actually
22
- # exists; the older Llama-3.1-Nemotron-Nano GGUF id 404s). Q8_0 (~4.3GB, near-fp16) over Q4_K_M:
23
- # ZeroGPU has ~48GB VRAM so size is a non-issue, and the better precision helps the "Eat That
24
- # Frog" prioritization reasoning. filename is a glob; Llama.from_pretrained resolves the exact
25
- # file. (Hard floor is Q4 — sub-4-bit is degraded for this hybrid architecture.)
26
- GGUF_REPO = "unsloth/NVIDIA-Nemotron-3-Nano-4B-GGUF"
27
- GGUF_FILE = "*Q8_0*.gguf"
28
-
29
- # Context length is hardware-adaptive (chosen in _get_llm from the REAL device's VRAM). On a big
30
- # GPU (ZeroGPU, 48GB) we use the full 128k Nemotron supports; on a small standard GPU (e.g. T4
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. No-op if offline or on a fresh local checkout
39
- # the model still lazy-loads from cache/network inside the GPU call either way.
40
- try:
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 torch
92
- dirs.append(os.path.join(os.path.dirname(torch.__file__), "lib"))
 
 
93
  except Exception:
94
  pass
95
- site_dirs = []
96
- if hasattr(site, "getsitepackages"):
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 (~2.5GB) then disk-caches it, so later calls are fast.
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
- _preload_cuda_libs()
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
- @spaces.GPU(duration=70)
141
- def generate_quests_raw(todos: str, theme: str) -> dict:
 
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
- content = out["choices"][0]["message"]["content"]
157
- return _extract_json(content)
158
 
159
 
160
- @spaces.GPU(duration=45)
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). Returns the raw classification; the caller decides
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 = _extract_json(out["choices"][0]["message"]["content"])
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
- def _extract_json(text: str) -> dict:
188
- """Parse JSON from the model output, tolerating stray prose or code fences."""
189
- text = (text or "").strip()
190
- try:
191
- return json.loads(text)
192
- except json.JSONDecodeError:
193
- pass
194
- # Fallback: grab the outermost { ... } span.
195
- start, end = text.find("{"), text.rfind("}")
196
- if start != -1 and end != -1 and end > start:
197
- try:
198
- return json.loads(text[start : end + 1])
199
- except json.JSONDecodeError:
200
- pass
201
- return {}
 
 
 
 
 
 
 
 
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