Codex Claude Opus 4.8 commited on
Commit
5bdf0f6
·
1 Parent(s): c1939e1

Modal-backed Space: run models on Modal GPU endpoints, Space is a thin HTTP client

Browse files

- modal_app.py: MiniCPM (cards) + Nemotron (boss) as OpenAI-compatible chat
endpoints, SDXL-Turbo (art) returning a data URI; warm A10G containers + a
cached HF volume.
- art.py/clients.py: ModalArtClient + a 'modal' art backend.
- app_hf.py: endpoint-based backends (card/boss over HTTP, art via modal);
endpoint URLs come from Space variables. No GPU on the Space.
- requirements: slim to gradio only; README -> cpu-basic, Modal-backed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (6) hide show
  1. README.md +2 -2
  2. app_hf.py +23 -16
  3. art.py +33 -0
  4. clients.py +13 -2
  5. modal_app.py +131 -0
  6. requirements.txt +0 -10
README.md CHANGED
@@ -6,7 +6,7 @@ colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 6.17.3
8
  app_file: app_hf.py
9
- suggested_hardware: zero-a10g
10
  pinned: false
11
  license: mit
12
  tags:
@@ -64,7 +64,7 @@ All listed models are under the hackathon's 32B parameter limit.
64
  | Boss AI | `nvidia/Nemotron-Mini-4B-Instruct` | Enemy play decisions |
65
  | Art | `stabilityai/sdxl-turbo` | Fast card illustration |
66
 
67
- The Hugging Face Space entry point is [app_hf.py](app_hf.py). It defaults to Transformers/Diffusers backends and wraps model loading/inference with Hugging Face ZeroGPU so GPU work runs only inside allocated calls, without requiring a local llama.cpp server.
68
 
69
  ## Running Locally
70
 
 
6
  sdk: gradio
7
  sdk_version: 6.17.3
8
  app_file: app_hf.py
9
+ suggested_hardware: cpu-basic
10
  pinned: false
11
  license: mit
12
  tags:
 
64
  | Boss AI | `nvidia/Nemotron-Mini-4B-Instruct` | Enemy play decisions |
65
  | Art | `stabilityai/sdxl-turbo` | Fast card illustration |
66
 
67
+ The Hugging Face Space entry point is [app_hf.py](app_hf.py). The Space is a thin client: MiniCPM (cards), Nemotron (boss), and SDXL-Turbo (art) run on dedicated Modal GPU endpoints (see [modal_app.py](modal_app.py)), which the Space calls over HTTP. This keeps heavy compute off the Space (free CPU hardware) and gives each model its own autoscaled GPU.
68
 
69
  ## Running Locally
70
 
app_hf.py CHANGED
@@ -1,33 +1,40 @@
1
- """Hugging Face Space entry point for Tabras.
2
 
3
- Runs the game with on-device Transformers/Diffusers backends (no MLX and no
4
- llama-server subprocess), so it works on a CUDA GPU Space. Every model id and
5
- generation setting below is a default that a Space variable/secret can override.
 
 
 
 
 
 
6
  """
7
 
8
  import os
9
 
10
- # MiniCPM authors the cards through its Transformers chat method.
11
- os.environ.setdefault("TABRAS_CARD_BACKEND", "transformers")
12
- os.environ.setdefault("TABRAS_CARD_MODEL", "openbmb/MiniCPM-V-4")
13
  os.environ.setdefault("TABRAS_CARD_MAX_TOKENS", "192")
 
 
14
 
15
- # Nemotron plays the boss.
16
  os.environ.setdefault("TABRAS_AI_BOSS", "1")
17
- os.environ.setdefault("TABRAS_BOSS_BACKEND", "transformers")
18
- os.environ.setdefault("TABRAS_BOSS_MODEL", "nvidia/Nemotron-Mini-4B-Instruct")
19
  os.environ.setdefault("TABRAS_BOSS_MAX_TOKENS", "96")
 
20
 
21
- # SDXL-Turbo illustrates the cards and the run backgrounds.
22
- os.environ.setdefault("TABRAS_ART_BACKEND", "diffusers")
23
- os.environ.setdefault("TABRAS_ART_MODEL", "stabilityai/sdxl-turbo")
24
  os.environ.setdefault("TABRAS_ART_STEPS", "4")
25
 
26
  from app import CSS, HEAD, build_app # noqa: E402 (import after env is configured)
27
 
28
- # Module-level `demo` so Hugging Face's launcher can find the Blocks. The CSS/JS
29
- # are applied via launch() below; that requires the Space to run this file
30
- # normally (Dev Mode OFF), not Gradio reload mode.
31
  demo = build_app()
32
  demo.queue()
33
 
 
1
+ """Hugging Face Space entry point for Tabras (Modal-backed).
2
 
3
+ The Space is a thin client: MiniCPM (cards), Nemotron (boss), and SDXL (art) all
4
+ run on Modal GPU endpoints (see modal_app.py). Set these Space variables to the
5
+ URLs printed by `modal deploy modal_app.py`:
6
+
7
+ TABRAS_CARD_ENDPOINT -> CardModel.chat URL
8
+ TABRAS_BOSS_ENDPOINT -> BossModel.chat URL
9
+ TABRAS_ART_ENDPOINT -> ArtModel.generate URL
10
+
11
+ No GPU is needed on the Space itself, so it runs on free CPU hardware.
12
  """
13
 
14
  import os
15
 
16
+ # Cards via the Modal MiniCPM endpoint (OpenAI-compatible chat over HTTP).
17
+ os.environ.setdefault("TABRAS_CARD_BACKEND", "llamacpp")
18
+ os.environ.setdefault("TABRAS_CARD_MODEL", "minicpm")
19
  os.environ.setdefault("TABRAS_CARD_MAX_TOKENS", "192")
20
+ os.environ.setdefault("TABRAS_CARD_TEMPERATURE", "0.7")
21
+ os.environ.setdefault("TABRAS_CARD_TIMEOUT", "120")
22
 
23
+ # Boss via the Modal Nemotron endpoint (LocalChatClient HTTP path).
24
  os.environ.setdefault("TABRAS_AI_BOSS", "1")
25
+ os.environ.setdefault("TABRAS_BOSS_BACKEND", "openai")
26
+ os.environ.setdefault("TABRAS_BOSS_MODEL", "nemotron")
27
  os.environ.setdefault("TABRAS_BOSS_MAX_TOKENS", "96")
28
+ os.environ.setdefault("TABRAS_BOSS_TIMEOUT", "120")
29
 
30
+ # Art via the Modal SDXL endpoint.
31
+ os.environ.setdefault("TABRAS_ART_BACKEND", "modal")
 
32
  os.environ.setdefault("TABRAS_ART_STEPS", "4")
33
 
34
  from app import CSS, HEAD, build_app # noqa: E402 (import after env is configured)
35
 
36
+ # Module-level `demo` so Hugging Face's launcher finds the Blocks; css/head are
37
+ # applied via launch() below (keep the Space's Dev Mode off so this runs).
 
38
  demo = build_app()
39
  demo.queue()
40
 
art.py CHANGED
@@ -1,8 +1,10 @@
1
  import base64
 
2
  import threading
3
  from dataclasses import dataclass, field, replace
4
  from io import BytesIO
5
  from typing import Any, Callable, Protocol, Sequence
 
6
 
7
  from budget import Card
8
  from zerogpu import gpu
@@ -13,6 +15,37 @@ NEGATIVE_ART_PROMPT = (
13
  )
14
 
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  class ArtClient(Protocol):
17
  # Return a browser-renderable image URI for one prompt.
18
  def create_art(self, prompt: str) -> str: # pragma: no cover
 
1
  import base64
2
+ import json
3
  import threading
4
  from dataclasses import dataclass, field, replace
5
  from io import BytesIO
6
  from typing import Any, Callable, Protocol, Sequence
7
+ from urllib import request
8
 
9
  from budget import Card
10
  from zerogpu import gpu
 
15
  )
16
 
17
 
18
+ @dataclass(frozen=True)
19
+ class ModalArtClient:
20
+ """Generate card art by calling a Modal SDXL endpoint; returns a data URI."""
21
+
22
+ endpoint: str
23
+ steps: int = 4
24
+ guidance_scale: float = 0.0
25
+ width: int = 512
26
+ height: int = 320
27
+ timeout_seconds: int = 120
28
+
29
+ # POST one prompt to the Modal art endpoint and return its image data URI.
30
+ def create_art(self, prompt: str) -> str:
31
+ payload = {
32
+ "prompt": prompt,
33
+ "steps": self.steps,
34
+ "guidance": self.guidance_scale,
35
+ "width": self.width,
36
+ "height": self.height,
37
+ "negative_prompt": NEGATIVE_ART_PROMPT,
38
+ }
39
+ req = request.Request(
40
+ self.endpoint,
41
+ data=json.dumps(payload).encode("utf-8"),
42
+ headers={"Content-Type": "application/json"},
43
+ method="POST",
44
+ )
45
+ with request.urlopen(req, timeout=self.timeout_seconds) as response:
46
+ return str(json.loads(response.read().decode("utf-8"))["image"])
47
+
48
+
49
  class ArtClient(Protocol):
50
  # Return a browser-renderable image URI for one prompt.
51
  def create_art(self, prompt: str) -> str: # pragma: no cover
clients.py CHANGED
@@ -1,6 +1,6 @@
1
  import os
2
 
3
- from art import ArtClient, DiffusersImageClient, LazyArtClient
4
  from boss import BossClient, NemotronBossClient
5
  from generator import CardPackClient, LlamaCppCardClient, MiniCPMCardClient
6
  from local_llm import (
@@ -107,7 +107,18 @@ def boss_client_from_env() -> BossClient | None:
107
  # Build an art-generation client from environment variables.
108
  def art_client_from_env() -> ArtClient | None:
109
  global _art_client_cache
110
- if os.environ.get("TABRAS_ART_BACKEND") != "diffusers":
 
 
 
 
 
 
 
 
 
 
 
111
  return None
112
  if _art_client_cache is None:
113
  model_id = os.environ.get("TABRAS_ART_MODEL", DEFAULT_ART_MODEL)
 
1
  import os
2
 
3
+ from art import ArtClient, DiffusersImageClient, LazyArtClient, ModalArtClient
4
  from boss import BossClient, NemotronBossClient
5
  from generator import CardPackClient, LlamaCppCardClient, MiniCPMCardClient
6
  from local_llm import (
 
107
  # Build an art-generation client from environment variables.
108
  def art_client_from_env() -> ArtClient | None:
109
  global _art_client_cache
110
+ backend = os.environ.get("TABRAS_ART_BACKEND")
111
+ if backend == "modal":
112
+ if _art_client_cache is None:
113
+ _art_client_cache = ModalArtClient(
114
+ endpoint=os.environ["TABRAS_ART_ENDPOINT"],
115
+ steps=int(os.environ.get("TABRAS_ART_STEPS", "4")),
116
+ guidance_scale=float(os.environ.get("TABRAS_ART_GUIDANCE", "0.0")),
117
+ width=int(os.environ.get("TABRAS_ART_WIDTH", "512")),
118
+ height=int(os.environ.get("TABRAS_ART_HEIGHT", "320")),
119
+ )
120
+ return _art_client_cache
121
+ if backend != "diffusers":
122
  return None
123
  if _art_client_cache is None:
124
  model_id = os.environ.get("TABRAS_ART_MODEL", DEFAULT_ART_MODEL)
modal_app.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal GPU endpoints for Tabras: MiniCPM card authoring, Nemotron boss play,
2
+ and SDXL-Turbo art. The Gradio Space calls these over HTTP, so all heavy compute
3
+ runs on dedicated, autoscaled Modal GPUs instead of the Space.
4
+
5
+ Deploy: modal deploy modal_app.py
6
+ Then set the printed URLs as Space variables:
7
+ TABRAS_CARD_ENDPOINT -> CardModel.chat URL
8
+ TABRAS_BOSS_ENDPOINT -> BossModel.chat URL
9
+ TABRAS_ART_ENDPOINT -> ArtModel.generate URL
10
+ """
11
+
12
+ import modal
13
+
14
+ CACHE = "/cache"
15
+ hf_cache = modal.Volume.from_name("tabras-hf-cache", create_if_missing=True)
16
+
17
+ MINICPM = "openbmb/MiniCPM-V-4"
18
+ NEMOTRON = "nvidia/Nemotron-Mini-4B-Instruct"
19
+ SDXL = "stabilityai/sdxl-turbo"
20
+
21
+ llm_image = (
22
+ modal.Image.debian_slim(python_version="3.11")
23
+ .pip_install(
24
+ "torch", "transformers==4.49.0", "accelerate", "sentencepiece",
25
+ "torchvision", "einops", "pillow", "fastapi[standard]",
26
+ )
27
+ .env({"HF_HOME": CACHE})
28
+ )
29
+ art_image = (
30
+ modal.Image.debian_slim(python_version="3.11")
31
+ .pip_install(
32
+ "torch", "diffusers", "transformers==4.49.0", "accelerate",
33
+ "safetensors", "pillow", "fastapi[standard]",
34
+ )
35
+ .env({"HF_HOME": CACHE})
36
+ )
37
+
38
+ app = modal.App("tabras-models")
39
+
40
+
41
+ # ---- MiniCPM: card authoring (OpenAI-compatible chat) ----
42
+ @app.cls(image=llm_image, gpu="A10G", volumes={CACHE: hf_cache}, min_containers=1, max_containers=2, scaledown_window=600, timeout=600)
43
+ class CardModel:
44
+ @modal.enter()
45
+ def load(self) -> None:
46
+ import torch
47
+ from transformers import AutoModel, AutoTokenizer
48
+
49
+ self.tok = AutoTokenizer.from_pretrained(MINICPM, trust_remote_code=True)
50
+ self.model = (
51
+ AutoModel.from_pretrained(MINICPM, trust_remote_code=True, attn_implementation="sdpa", torch_dtype=torch.float16)
52
+ .eval()
53
+ .cuda()
54
+ )
55
+
56
+ @modal.fastapi_endpoint(method="POST")
57
+ def chat(self, item: dict) -> dict:
58
+ msgs = item.get("messages", [])
59
+ system = " ".join(m["content"] for m in msgs if m.get("role") == "system")
60
+ user = " ".join(m["content"] for m in msgs if m.get("role") == "user")
61
+ temp = float(item.get("temperature", 0.7))
62
+ text = str(
63
+ self.model.chat(
64
+ msgs=[{"role": "user", "content": user}],
65
+ image=None,
66
+ tokenizer=self.tok,
67
+ system_prompt=system,
68
+ sampling=temp > 0,
69
+ temperature=max(temp, 0.01),
70
+ max_new_tokens=int(item.get("max_tokens", 192)),
71
+ )
72
+ )
73
+ return {"choices": [{"message": {"role": "assistant", "content": text}}]}
74
+
75
+
76
+ # ---- Nemotron: boss play (OpenAI-compatible chat) ----
77
+ @app.cls(image=llm_image, gpu="A10G", volumes={CACHE: hf_cache}, min_containers=1, max_containers=2, scaledown_window=600, timeout=600)
78
+ class BossModel:
79
+ @modal.enter()
80
+ def load(self) -> None:
81
+ import torch
82
+ from transformers import AutoModelForCausalLM, AutoTokenizer
83
+
84
+ self.tok = AutoTokenizer.from_pretrained(NEMOTRON)
85
+ self.model = AutoModelForCausalLM.from_pretrained(NEMOTRON, torch_dtype=torch.float16).eval().cuda()
86
+
87
+ @modal.fastapi_endpoint(method="POST")
88
+ def chat(self, item: dict) -> dict:
89
+ import torch
90
+
91
+ msgs = item.get("messages", [])
92
+ inputs = self.tok.apply_chat_template(msgs, tokenize=True, add_generation_prompt=True, return_tensors="pt").to("cuda")
93
+ temp = float(item.get("temperature", 0.2))
94
+ with torch.no_grad():
95
+ out = self.model.generate(
96
+ inputs,
97
+ max_new_tokens=int(item.get("max_tokens", 96)),
98
+ do_sample=temp > 0,
99
+ temperature=max(temp, 0.01),
100
+ )
101
+ text = self.tok.decode(out[0][inputs.shape[-1]:], skip_special_tokens=True)
102
+ return {"choices": [{"message": {"role": "assistant", "content": text}}]}
103
+
104
+
105
+ # ---- SDXL-Turbo: card art (returns a JPEG data URI) ----
106
+ @app.cls(image=art_image, gpu="A10G", volumes={CACHE: hf_cache}, min_containers=1, max_containers=2, scaledown_window=600, timeout=600)
107
+ class ArtModel:
108
+ @modal.enter()
109
+ def load(self) -> None:
110
+ import torch
111
+ from diffusers import AutoPipelineForText2Image
112
+
113
+ self.pipe = AutoPipelineForText2Image.from_pretrained(SDXL, torch_dtype=torch.float16).to("cuda")
114
+ self.pipe.set_progress_bar_config(disable=True)
115
+
116
+ @modal.fastapi_endpoint(method="POST")
117
+ def generate(self, item: dict) -> dict:
118
+ import base64
119
+ from io import BytesIO
120
+
121
+ result = self.pipe(
122
+ prompt=item["prompt"],
123
+ num_inference_steps=int(item.get("steps", 4)),
124
+ guidance_scale=float(item.get("guidance", 0.0)),
125
+ width=int(item.get("width", 512)),
126
+ height=int(item.get("height", 320)),
127
+ negative_prompt=item.get("negative_prompt"),
128
+ )
129
+ buffer = BytesIO()
130
+ result.images[0].save(buffer, format="JPEG", quality=85)
131
+ return {"image": "data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")}
requirements.txt CHANGED
@@ -1,11 +1 @@
1
  gradio==6.17.3
2
- diffusers
3
- torch
4
- transformers==4.49.0
5
- accelerate
6
- safetensors
7
- pillow
8
- sentencepiece
9
- torchvision
10
- einops
11
- spaces
 
1
  gradio==6.17.3