Fsezai33 commited on
Commit
a9be9e3
·
verified ·
1 Parent(s): 2aab1f8

Add backend router and GPU GGUF llama.cpp support

Browse files
Files changed (5) hide show
  1. README.md +31 -14
  2. app.py +201 -60
  3. backend_router.py +374 -0
  4. model_manager.py +316 -112
  5. requirements.txt +6 -1
README.md CHANGED
@@ -6,38 +6,55 @@ colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 5.50.0
8
  app_file: app.py
9
- short_description: Load and test HF causal LLMs on ZeroGPU
10
  python_version: "3.10"
11
  startup_duration_timeout: 1h
12
  ---
13
 
14
  # Dynamic LLM ZeroGPU Playground
15
 
16
- A small, general-purpose playground for testing standard Hugging Face causal
17
- language models by model ID.
 
18
 
19
  ## How it works
20
 
21
- 1. Enter a model ID such as `Qwen/Qwen2.5-0.5B-Instruct`.
22
- 2. Click **Download** to fetch the snapshot into the Space's CPU-side cache.
23
- 3. Click **Load** to put that model on the ZeroGPU worker.
24
- 4. Chat with the model, or use **Unload** before switching models.
25
- 5. Use **Delete from disk** to unload first, then remove all cached revisions for the selected ID.
 
 
 
26
 
27
  Only one model is kept active by the runtime. Switching models releases the
28
  previous model with `del`, `gc.collect()`, and `torch.cuda.empty_cache()` before
29
  the new model is loaded. Chat templates are used whenever the tokenizer
30
  provides `apply_chat_template()`.
31
 
32
- This MVP intentionally targets standard `transformers` +
33
- `AutoModelForCausalLM` checkpoints. The loader is isolated in
34
- `TransformersCausalLMRuntime`, so AWQ, GPTQ, or FP8 backends can be added later.
 
 
 
 
 
 
 
 
 
 
35
 
36
  ## Notes
37
 
38
  - Model downloads happen on CPU and are never triggered by the chat handler.
39
  - A model must be downloaded before it can be loaded or used.
40
  - Large models may exceed ZeroGPU memory or take a long time to load.
41
- - Remote model code is disabled in this first version for safety and stability.
42
- - GGUF-only, vision, and other non-causal checkpoints are detected and reported
43
- as unsupported instead of producing an opaque Transformers traceback.
 
 
 
 
6
  sdk: gradio
7
  sdk_version: 5.50.0
8
  app_file: app.py
9
+ short_description: Test Transformers and GGUF LLM quants on ZeroGPU
10
  python_version: "3.10"
11
  startup_duration_timeout: 1h
12
  ---
13
 
14
  # Dynamic LLM ZeroGPU Playground
15
 
16
+ A small, general-purpose playground for testing Hugging Face LLMs by model ID.
17
+ It supports standard Transformers checkpoints and direct, non-dequantized GGUF
18
+ inference through a CUDA-enabled llama.cpp backend.
19
 
20
  ## How it works
21
 
22
+ 1. Enter a model ID.
23
+ 2. Click **Inspect / list GGUF**. If the repository contains GGUF, choose one
24
+ quant file such as Q4_K_M, Q5_K_M, Q8_0, IQ4, or a newer type.
25
+ 3. Choose **Backend: Auto** (recommended), or force Transformers/llama.cpp.
26
+ 4. Click **Download**. Standard repositories use a CPU-side snapshot download;
27
+ GGUF repositories download only the selected `.gguf` file.
28
+ 5. Click **Load**, then chat. Use **Unload** before switching models and
29
+ **Delete from disk** to remove the cached revisions/files.
30
 
31
  Only one model is kept active by the runtime. Switching models releases the
32
  previous model with `del`, `gc.collect()`, and `torch.cuda.empty_cache()` before
33
  the new model is loaded. Chat templates are used whenever the tokenizer
34
  provides `apply_chat_template()`.
35
 
36
+ ## Backend routing
37
+
38
+ - `Auto` routes GGUF to llama.cpp and Transformers weight repositories to
39
+ `AutoModelForCausalLM`.
40
+ - AWQ, GPTQ, bitsandbytes 4/8-bit, compressed-tensors, and FP8 metadata are
41
+ detected from `config.json` and filenames. Transformers receives the
42
+ repository quantization config and uses the installed optional loaders.
43
+ - GGUF is never passed to Transformers or dequantized. llama.cpp is loaded with
44
+ `n_gpu_layers=-1`, and the GGUF's embedded chat template/metadata is used by
45
+ the Python binding for current Qwen and other supported architectures.
46
+ - Only one model/backend is active at a time. Cleanup calls `del`,
47
+ `gc.collect()`, `torch.cuda.empty_cache()`, and llama.cpp's close method when
48
+ available.
49
 
50
  ## Notes
51
 
52
  - Model downloads happen on CPU and are never triggered by the chat handler.
53
  - A model must be downloaded before it can be loaded or used.
54
  - Large models may exceed ZeroGPU memory or take a long time to load.
55
+ - Remote model code is disabled for safety and stability.
56
+ - A GGUF repository normally embeds its tokenizer/chat metadata, so the large
57
+ companion files are not downloaded. Multimodal `mmproj` files are listed but
58
+ are not selected as the default quant.
59
+ - Very large models can still exceed the temporary ZeroGPU memory budget; Q4/Q5
60
+ GGUF files are generally the best starting point on an A10G.
app.py CHANGED
@@ -1,4 +1,4 @@
1
- """Dynamic Hugging Face causal-LLM playground for ZeroGPU Spaces."""
2
 
3
  from __future__ import annotations
4
 
@@ -8,17 +8,18 @@ from typing import Any
8
 
9
  os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
10
 
11
- # ZeroGPU must be imported before torch or any library that may touch CUDA.
12
  import spaces
13
- import torch
14
  import gradio as gr
15
 
16
- from model_manager import (
17
- ModelCache,
18
- TransformersCausalLMRuntime,
19
- UnsupportedModelError,
20
- validate_model_id,
21
  )
 
22
 
23
 
24
  logging.basicConfig(level=logging.INFO)
@@ -26,13 +27,13 @@ LOGGER = logging.getLogger(__name__)
26
 
27
  DEFAULT_MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
28
  cache = ModelCache()
29
- runtime = TransformersCausalLMRuntime(cache)
30
 
31
 
32
  def _short_error(prefix: str, exc: Exception) -> str:
33
  LOGGER.exception("%s", prefix)
34
  detail = str(exc).strip().splitlines()[0] if str(exc).strip() else exc.__class__.__name__
35
- return f"Error: {prefix} {detail[:300]}"
36
 
37
 
38
  def _safe_generation_settings(
@@ -46,40 +47,114 @@ def _safe_generation_settings(
46
  return tokens, temp, nucleus
47
 
48
 
49
- def download_model(model_id: str) -> tuple[str, str]:
50
- """Download a model snapshot on CPU into the playground cache."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
  try:
53
  model_id = validate_model_id(model_id)
54
- snapshot_path = cache.download(model_id)
55
- try:
56
- cache.ensure_transformers_checkpoint(model_id)
57
- status = f"Downloaded on CPU: `{model_id}`"
58
- except UnsupportedModelError as exc:
59
- status = f"Downloaded on CPU, but this MVP cannot Load it: {exc}"
 
60
  return (
61
  status,
62
- f"Disk cache: ready ({snapshot_path.name}).",
 
 
63
  )
64
  except Exception as exc:
65
- message = _short_error("Could not download the model:", exc)
66
- return message, cache.describe(model_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
 
69
  @spaces.GPU(duration=420)
70
- def load_model_on_gpu(model_id: str) -> tuple[str, str, str]:
71
- """Load one downloaded Transformers causal LM on the ZeroGPU worker."""
 
 
 
 
72
 
73
  try:
74
  model_id = validate_model_id(model_id)
75
- active = runtime.ensure_loaded(model_id)
 
76
  return (
77
- f"Loaded on ZeroGPU: `{active}`",
78
- active,
79
- cache.describe(active),
 
80
  )
81
  except Exception as exc:
82
- return _short_error("Could not load the model:", exc), "No model loaded", cache.describe(model_id)
 
 
 
 
 
83
 
84
 
85
  @spaces.GPU(duration=180)
@@ -87,12 +162,14 @@ def chat_with_model(
87
  message: str,
88
  history: list[Any] | None,
89
  model_id: str,
 
 
90
  system_prompt: str,
91
  max_new_tokens: Any,
92
  temperature: Any,
93
  top_p: Any,
94
  ) -> str:
95
- """Generate a reply using the selected cached Hugging Face model."""
96
 
97
  try:
98
  model_id = validate_model_id(model_id)
@@ -101,6 +178,8 @@ def chat_with_model(
101
  )
102
  return runtime.generate(
103
  model_id=model_id,
 
 
104
  message=message,
105
  history=history,
106
  system_prompt=system_prompt,
@@ -113,29 +192,55 @@ def chat_with_model(
113
 
114
 
115
  @spaces.GPU(duration=30)
116
- def unload_model_on_gpu(model_id: str) -> tuple[str, str, str]:
117
- """Unload the active model and release RAM/VRAM on the ZeroGPU worker."""
118
 
119
  try:
120
  runtime.unload()
121
- return "Unloaded; RAM/VRAM cleanup requested.", "No model loaded", cache.describe(model_id)
 
 
 
 
 
122
  except Exception as exc:
123
- return _short_error("Could not unload the model:", exc), "Unknown", cache.describe(model_id)
 
 
 
 
 
124
 
125
 
126
- def delete_model_from_disk(model_id: str) -> tuple[str, str, str]:
127
- """Delete every cached revision of the selected model from disk."""
 
 
 
128
 
129
  try:
130
  model_id = validate_model_id(model_id)
131
  deleted = cache.delete(model_id)
132
- if deleted:
133
- status = f"Deleted from disk: `{model_id}`"
134
- else:
135
- status = f"No cached files found for `{model_id}`"
136
- return status, "No model loaded", cache.describe(model_id)
 
 
 
 
 
 
 
137
  except Exception as exc:
138
- return _short_error("Could not delete the model cache:", exc), "Unknown", cache.describe(model_id)
 
 
 
 
 
 
139
 
140
 
141
  CSS = """
@@ -144,15 +249,15 @@ CSS = """
144
  """
145
 
146
 
147
- with gr.Blocks(title="Dynamic LLM ZeroGPU Playground", css=CSS) as demo:
148
  gr.Markdown(
149
  """
150
- # Dynamic LLM ZeroGPU Playground
151
 
152
- Download standard `transformers` causal language models, load one model
153
- at a time on ZeroGPU, and test it through a Gradio chat interface.
154
- Downloading and cache management stay on CPU; model loading and
155
- inference use the GPU only when requested.
156
  """
157
  )
158
 
@@ -163,6 +268,25 @@ with gr.Blocks(title="Dynamic LLM ZeroGPU Playground", css=CSS) as demo:
163
  placeholder="namespace/model-name",
164
  scale=4,
165
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  download_button = gr.Button("Download", variant="secondary", scale=1)
167
  load_button = gr.Button("Load", variant="primary", scale=1)
168
  unload_button = gr.Button("Unload", variant="secondary", scale=1)
@@ -170,14 +294,17 @@ with gr.Blocks(title="Dynamic LLM ZeroGPU Playground", css=CSS) as demo:
170
 
171
  with gr.Row():
172
  current_model = gr.Textbox(
173
- label="Active model",
174
  value="No model loaded",
175
  interactive=False,
176
  scale=1,
177
  )
178
  cache_status = gr.Markdown("Disk cache: no model selected.")
179
 
180
- status = gr.Markdown("Status: enter a model ID, then click Download.")
 
 
 
181
 
182
  with gr.Accordion("Generation settings", open=True):
183
  system_prompt = gr.Textbox(
@@ -199,40 +326,53 @@ with gr.Blocks(title="Dynamic LLM ZeroGPU Playground", css=CSS) as demo:
199
  fn=chat_with_model,
200
  chatbot=chatbot,
201
  type="messages",
202
- additional_inputs=[model_id, system_prompt, max_new_tokens, temperature, top_p],
 
 
 
 
 
 
 
 
203
  textbox=gr.Textbox(placeholder="Write a message…", container=False),
204
  api_name="chat",
205
  )
206
 
 
 
 
 
 
 
207
  download_button.click(
208
  fn=download_model,
209
- inputs=[model_id],
210
- outputs=[status, cache_status],
211
  api_name="download",
212
  )
213
  load_button.click(
214
  fn=load_model_on_gpu,
215
- inputs=[model_id],
216
- outputs=[status, current_model, cache_status],
217
  api_name="load",
218
  )
219
  unload_button.click(
220
  fn=unload_model_on_gpu,
221
  inputs=[model_id],
222
- outputs=[status, current_model, cache_status],
223
  api_name="unload",
224
  )
225
  delete_event = delete_button.click(
226
- # Release a possibly active GPU copy before removing its CPU cache.
227
- # The actual deletion remains a CPU-only operation in the next step.
228
  fn=unload_model_on_gpu,
229
  inputs=[model_id],
230
- outputs=[status, current_model, cache_status],
231
  )
232
  delete_event.then(
233
  fn=delete_model_from_disk,
234
- inputs=[model_id],
235
- outputs=[status, current_model, cache_status],
236
  api_name="delete_from_disk",
237
  )
238
 
@@ -240,3 +380,4 @@ with gr.Blocks(title="Dynamic LLM ZeroGPU Playground", css=CSS) as demo:
240
  if __name__ == "__main__":
241
  demo.queue(default_concurrency_limit=1)
242
  demo.launch(mcp_server=True)
 
 
1
+ """Dynamic quantized LLM playground for Hugging Face ZeroGPU Spaces."""
2
 
3
  from __future__ import annotations
4
 
 
8
 
9
  os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
10
 
11
+ # ZeroGPU must be imported before torch or a library that may initialize CUDA.
12
  import spaces
13
+ import torch # noqa: F401 # imported after spaces by design
14
  import gradio as gr
15
 
16
+ from backend_router import (
17
+ BACKEND_AUTO,
18
+ BACKEND_CHOICES,
19
+ BackendRouterError,
20
+ ModelInspection,
21
  )
22
+ from model_manager import ModelCache, ModelRuntime, validate_model_id
23
 
24
 
25
  logging.basicConfig(level=logging.INFO)
 
27
 
28
  DEFAULT_MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
29
  cache = ModelCache()
30
+ runtime = ModelRuntime(cache)
31
 
32
 
33
  def _short_error(prefix: str, exc: Exception) -> str:
34
  LOGGER.exception("%s", prefix)
35
  detail = str(exc).strip().splitlines()[0] if str(exc).strip() else exc.__class__.__name__
36
+ return f"Error: {prefix} {detail[:320]}"
37
 
38
 
39
  def _safe_generation_settings(
 
47
  return tokens, temp, nucleus
48
 
49
 
50
+ def _dropdown_update(inspection: ModelInspection | None) -> Any:
51
+ choices = inspection.gguf_files if inspection else []
52
+ value = inspection.default_gguf if inspection else None
53
+ return gr.update(choices=choices, value=value)
54
+
55
+
56
+ def _inspection_markdown(
57
+ inspection: ModelInspection | None,
58
+ backend: str | None = None,
59
+ selected_file: str | None = None,
60
+ ) -> str:
61
+ if inspection is None:
62
+ return "**Detected format:** not inspected yet \n**Backend:** Auto"
63
+ return inspection.markdown(backend, selected_file)
64
+
65
+
66
+ def inspect_model(model_id: str) -> tuple[str, Any, str, str]:
67
+ """Inspect repository metadata and list GGUF choices without downloading weights."""
68
 
69
  try:
70
  model_id = validate_model_id(model_id)
71
+ inspection = cache.inspect_remote(model_id)
72
+ selected = inspection.default_gguf
73
+ status = f"Inspected `{model_id}` on CPU."
74
+ if inspection.gguf_files:
75
+ status += " Select a GGUF file, then click Download."
76
+ else:
77
+ status += " No GGUF file was found; Auto will use Transformers."
78
  return (
79
  status,
80
+ _dropdown_update(inspection),
81
+ _inspection_markdown(inspection, selected_file=selected),
82
+ cache.describe(model_id, selected),
83
  )
84
  except Exception as exc:
85
+ return (
86
+ _short_error("Could not inspect the repository:", exc),
87
+ _dropdown_update(None),
88
+ _inspection_markdown(None),
89
+ cache.describe(model_id),
90
+ )
91
+
92
+
93
+ def download_model(
94
+ model_id: str,
95
+ backend_choice: str,
96
+ gguf_file: str | None,
97
+ ) -> tuple[str, Any, str, str]:
98
+ """Download a standard snapshot or exactly one selected GGUF on CPU."""
99
+
100
+ try:
101
+ model_id = validate_model_id(model_id)
102
+ inspection = cache.inspect_remote(model_id)
103
+ selected = (gguf_file or inspection.default_gguf or "").strip()
104
+ backend = cache.router.resolve_backend(inspection, backend_choice, selected or None)
105
+
106
+ if backend == "llama.cpp":
107
+ path = cache.download_gguf(model_id, selected)
108
+ status = f"Downloaded one GGUF file on CPU: `{selected}`"
109
+ cache_status = cache.describe(model_id, selected)
110
+ return (
111
+ status,
112
+ _dropdown_update(inspection),
113
+ _inspection_markdown(inspection, backend, selected),
114
+ cache_status,
115
+ )
116
+
117
+ path = cache.download(model_id)
118
+ return (
119
+ f"Downloaded Transformers files on CPU: `{model_id}`",
120
+ _dropdown_update(inspection),
121
+ _inspection_markdown(inspection, backend),
122
+ f"Disk cache: snapshot ready (`{path.name}`).",
123
+ )
124
+ except Exception as exc:
125
+ return (
126
+ _short_error("Could not download the model:", exc),
127
+ _dropdown_update(None),
128
+ _inspection_markdown(None),
129
+ cache.describe(model_id, gguf_file),
130
+ )
131
 
132
 
133
  @spaces.GPU(duration=420)
134
+ def load_model_on_gpu(
135
+ model_id: str,
136
+ backend_choice: str,
137
+ gguf_file: str | None,
138
+ ) -> tuple[str, str, str, str]:
139
+ """Load one selected model on ZeroGPU using the routed backend."""
140
 
141
  try:
142
  model_id = validate_model_id(model_id)
143
+ target = runtime.ensure_loaded(model_id, backend_choice, gguf_file)
144
+ selected = target.selected_file
145
  return (
146
+ f"Loaded on ZeroGPU: `{model_id}` via `{target.backend}`",
147
+ runtime.active_label(),
148
+ _inspection_markdown(target.inspection, target.backend, selected),
149
+ cache.describe(model_id, selected),
150
  )
151
  except Exception as exc:
152
+ return (
153
+ _short_error("Could not load the model:", exc),
154
+ "No model loaded",
155
+ _inspection_markdown(None),
156
+ cache.describe(model_id, gguf_file),
157
+ )
158
 
159
 
160
  @spaces.GPU(duration=180)
 
162
  message: str,
163
  history: list[Any] | None,
164
  model_id: str,
165
+ backend_choice: str,
166
+ gguf_file: str | None,
167
  system_prompt: str,
168
  max_new_tokens: Any,
169
  temperature: Any,
170
  top_p: Any,
171
  ) -> str:
172
+ """Generate a reply through the active Transformers or llama.cpp runtime."""
173
 
174
  try:
175
  model_id = validate_model_id(model_id)
 
178
  )
179
  return runtime.generate(
180
  model_id=model_id,
181
+ requested_backend=backend_choice,
182
+ selected_file=gguf_file,
183
  message=message,
184
  history=history,
185
  system_prompt=system_prompt,
 
192
 
193
 
194
  @spaces.GPU(duration=30)
195
+ def unload_model_on_gpu(model_id: str) -> tuple[str, str, str, str]:
196
+ """Unload the active runtime and release RAM/VRAM."""
197
 
198
  try:
199
  runtime.unload()
200
+ return (
201
+ "Unloaded; RAM/VRAM cleanup requested.",
202
+ "No model loaded",
203
+ "**Detected format:** none active \n**Backend:** none",
204
+ cache.describe(model_id),
205
+ )
206
  except Exception as exc:
207
+ return (
208
+ _short_error("Could not unload the model:", exc),
209
+ "Unknown",
210
+ _inspection_markdown(None),
211
+ cache.describe(model_id),
212
+ )
213
 
214
 
215
+ def delete_model_from_disk(
216
+ model_id: str,
217
+ gguf_file: str | None,
218
+ ) -> tuple[str, str, str, str, Any]:
219
+ """Remove all cached revisions/files for the selected model on CPU."""
220
 
221
  try:
222
  model_id = validate_model_id(model_id)
223
  deleted = cache.delete(model_id)
224
+ status = (
225
+ f"Deleted from disk: `{model_id}`"
226
+ if deleted
227
+ else f"No cached files found for `{model_id}`"
228
+ )
229
+ return (
230
+ status,
231
+ "No model loaded",
232
+ "**Detected format:** none active \n**Backend:** none",
233
+ cache.describe(model_id),
234
+ _dropdown_update(None),
235
+ )
236
  except Exception as exc:
237
+ return (
238
+ _short_error("Could not delete the model cache:", exc),
239
+ "Unknown",
240
+ _inspection_markdown(None),
241
+ cache.describe(model_id, gguf_file),
242
+ _dropdown_update(None),
243
+ )
244
 
245
 
246
  CSS = """
 
249
  """
250
 
251
 
252
+ with gr.Blocks(title="Quantized LLM ZeroGPU Playground", css=CSS) as demo:
253
  gr.Markdown(
254
  """
255
+ # Quantized LLM ZeroGPU Playground
256
 
257
+ Download and test one Hugging Face LLM at a time. **Auto** routes
258
+ standard Transformers checkpoints to Transformers and GGUF files to
259
+ the CUDA-enabled llama.cpp backend. GGUF repositories are inspected
260
+ first so you can select only the Q4/Q5/Q8 (or newer) quant you want.
261
  """
262
  )
263
 
 
268
  placeholder="namespace/model-name",
269
  scale=4,
270
  )
271
+ backend_choice = gr.Radio(
272
+ label="Backend",
273
+ choices=BACKEND_CHOICES,
274
+ value=BACKEND_AUTO,
275
+ scale=2,
276
+ )
277
+
278
+ with gr.Row():
279
+ inspect_button = gr.Button("Inspect / list GGUF", variant="secondary")
280
+ gguf_file = gr.Dropdown(
281
+ label="GGUF quant file (choose one)",
282
+ choices=[],
283
+ value=None,
284
+ allow_custom_value=False,
285
+ interactive=True,
286
+ scale=4,
287
+ )
288
+
289
+ with gr.Row():
290
  download_button = gr.Button("Download", variant="secondary", scale=1)
291
  load_button = gr.Button("Load", variant="primary", scale=1)
292
  unload_button = gr.Button("Unload", variant="secondary", scale=1)
 
294
 
295
  with gr.Row():
296
  current_model = gr.Textbox(
297
+ label="Active model / backend",
298
  value="No model loaded",
299
  interactive=False,
300
  scale=1,
301
  )
302
  cache_status = gr.Markdown("Disk cache: no model selected.")
303
 
304
+ status = gr.Markdown("Status: inspect a repository, then Download and Load it.")
305
+ format_backend = gr.Markdown(
306
+ "**Detected format:** not inspected yet \n**Backend:** Auto"
307
+ )
308
 
309
  with gr.Accordion("Generation settings", open=True):
310
  system_prompt = gr.Textbox(
 
326
  fn=chat_with_model,
327
  chatbot=chatbot,
328
  type="messages",
329
+ additional_inputs=[
330
+ model_id,
331
+ backend_choice,
332
+ gguf_file,
333
+ system_prompt,
334
+ max_new_tokens,
335
+ temperature,
336
+ top_p,
337
+ ],
338
  textbox=gr.Textbox(placeholder="Write a message…", container=False),
339
  api_name="chat",
340
  )
341
 
342
+ inspect_button.click(
343
+ fn=inspect_model,
344
+ inputs=[model_id],
345
+ outputs=[status, gguf_file, format_backend, cache_status],
346
+ api_name="inspect",
347
+ )
348
  download_button.click(
349
  fn=download_model,
350
+ inputs=[model_id, backend_choice, gguf_file],
351
+ outputs=[status, gguf_file, format_backend, cache_status],
352
  api_name="download",
353
  )
354
  load_button.click(
355
  fn=load_model_on_gpu,
356
+ inputs=[model_id, backend_choice, gguf_file],
357
+ outputs=[status, current_model, format_backend, cache_status],
358
  api_name="load",
359
  )
360
  unload_button.click(
361
  fn=unload_model_on_gpu,
362
  inputs=[model_id],
363
+ outputs=[status, current_model, format_backend, cache_status],
364
  api_name="unload",
365
  )
366
  delete_event = delete_button.click(
367
+ # Release a possibly active GPU copy before deleting its CPU cache.
 
368
  fn=unload_model_on_gpu,
369
  inputs=[model_id],
370
+ outputs=[status, current_model, format_backend, cache_status],
371
  )
372
  delete_event.then(
373
  fn=delete_model_from_disk,
374
+ inputs=[model_id, gguf_file],
375
+ outputs=[status, current_model, format_backend, cache_status, gguf_file],
376
  api_name="delete_from_disk",
377
  )
378
 
 
380
  if __name__ == "__main__":
381
  demo.queue(default_concurrency_limit=1)
382
  demo.launch(mcp_server=True)
383
+
backend_router.py ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CPU-side model inspection and backend routing.
2
+
3
+ The router never imports torch or initializes a model. It only looks at the
4
+ Hub file list and (when useful) the small ``config.json`` file. This keeps
5
+ download/inspection work outside ZeroGPU allocations and makes it possible to
6
+ add another runtime without changing the UI contract.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any, Iterable
16
+
17
+ from huggingface_hub import HfApi, hf_hub_download
18
+
19
+
20
+ BACKEND_AUTO = "Auto"
21
+ BACKEND_TRANSFORMERS = "Transformers"
22
+ BACKEND_LLAMACPP = "llama.cpp"
23
+ BACKEND_CHOICES = [BACKEND_AUTO, BACKEND_TRANSFORMERS, BACKEND_LLAMACPP]
24
+
25
+ QUANTIZED_TRANSFORMERS_KINDS = {
26
+ "awq",
27
+ "gptq",
28
+ "bitsandbytes",
29
+ "compressed-tensors",
30
+ "fp8",
31
+ }
32
+
33
+ _GGUF_QUANT_RE = re.compile(
34
+ r"(?i)(?:^|[_\-.])((?:iq|q|tq)\d+(?:[_\-][a-z0-9]+)*|mxfp4|nvfp4|fp8|bf16|f16|f32)(?:[_\-.]|$)"
35
+ )
36
+ _STANDARD_WEIGHT_NAMES = {
37
+ "model.safetensors.index.json",
38
+ "pytorch_model.bin.index.json",
39
+ }
40
+ _STANDARD_WEIGHT_SUFFIXES = (".safetensors", ".bin", ".pt", ".pth")
41
+
42
+
43
+ class BackendRouterError(ValueError):
44
+ """Raised when a model cannot be mapped to a supported backend."""
45
+
46
+
47
+ @dataclass
48
+ class ModelInspection:
49
+ """A small, serializable description of one Hub repository."""
50
+
51
+ model_id: str
52
+ files: list[str] = field(default_factory=list)
53
+ gguf_files: list[str] = field(default_factory=list)
54
+ has_standard_weights: bool = False
55
+ quantization_kind: str = "none"
56
+ format_label: str = "Unknown"
57
+ preferred_backend: str = BACKEND_TRANSFORMERS
58
+ config: dict[str, Any] = field(default_factory=dict)
59
+ source: str = "remote"
60
+
61
+ @property
62
+ def is_gguf(self) -> bool:
63
+ return bool(self.gguf_files)
64
+
65
+ @property
66
+ def is_transformers_quantized(self) -> bool:
67
+ return self.quantization_kind in QUANTIZED_TRANSFORMERS_KINDS
68
+
69
+ @property
70
+ def default_gguf(self) -> str | None:
71
+ """Prefer a normal LLM quant over a multimodal projector file."""
72
+
73
+ candidates = [
74
+ name for name in self.gguf_files if "mmproj" not in name.lower()
75
+ ] or list(self.gguf_files)
76
+ if not candidates:
77
+ return None
78
+
79
+ def rank(name: str) -> tuple[int, str]:
80
+ lowered = name.lower()
81
+ preferred = (
82
+ "q4_k_m",
83
+ "q5_k_m",
84
+ "q4_k_s",
85
+ "q5_k_s",
86
+ "q6_k",
87
+ "q8_0",
88
+ "iq4",
89
+ )
90
+ for index, token in enumerate(preferred):
91
+ if token in lowered:
92
+ return index, lowered
93
+ return len(preferred), lowered
94
+
95
+ return min(candidates, key=rank)
96
+
97
+ @property
98
+ def gguf_quantizations(self) -> list[str]:
99
+ values: set[str] = set()
100
+ for filename in self.gguf_files:
101
+ for match in _GGUF_QUANT_RE.finditer(filename):
102
+ values.add(match.group(1).replace("-", "_"))
103
+ return sorted(values, key=str.lower)
104
+
105
+ def markdown(self, resolved_backend: str | None = None, selected_file: str | None = None) -> str:
106
+ backend = resolved_backend or self.preferred_backend
107
+ lines = [
108
+ f"**Detected format:** `{self.format_label}` ",
109
+ f"**Backend:** `{backend}`",
110
+ ]
111
+ if selected_file:
112
+ lines.append(f" \n**Selected GGUF:** `{selected_file}`")
113
+ if self.gguf_files:
114
+ lines.append(
115
+ f" \n**GGUF files:** {len(self.gguf_files)} found; only the selected file is downloaded."
116
+ )
117
+ if self.is_transformers_quantized:
118
+ lines.append(
119
+ " \nThe Transformers quantization config will be passed to the corresponding loader."
120
+ )
121
+ return "\n".join(lines)
122
+
123
+
124
+ @dataclass
125
+ class ResolvedBackend:
126
+ """A cached model path plus the backend selected for it."""
127
+
128
+ backend: str
129
+ path: Path
130
+ inspection: ModelInspection
131
+ selected_file: str | None = None
132
+
133
+
134
+ def _is_gguf(filename: str) -> bool:
135
+ return filename.lower().endswith(".gguf")
136
+
137
+
138
+ def _has_standard_weights(files: Iterable[str]) -> bool:
139
+ return any(
140
+ name.lower().endswith(_STANDARD_WEIGHT_SUFFIXES)
141
+ or Path(name).name.lower() in _STANDARD_WEIGHT_NAMES
142
+ for name in files
143
+ )
144
+
145
+
146
+ def _read_json(path: Path) -> dict[str, Any]:
147
+ try:
148
+ value = json.loads(path.read_text(encoding="utf-8"))
149
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
150
+ return {}
151
+ return value if isinstance(value, dict) else {}
152
+
153
+
154
+ def _quantization_from_config(config: dict[str, Any]) -> tuple[str, str | None]:
155
+ raw_config = config.get("quantization_config")
156
+ quant_config = raw_config if isinstance(raw_config, dict) else {}
157
+ raw = json.dumps(quant_config, sort_keys=True).lower()
158
+ model_text = json.dumps(config, sort_keys=True).lower()
159
+
160
+ quant_method = str(
161
+ quant_config.get("quant_method")
162
+ or quant_config.get("quantization_method")
163
+ or quant_config.get("method")
164
+ or ""
165
+ ).lower()
166
+
167
+ if "bitsandbytes" in quant_method or any(
168
+ key in quant_config
169
+ for key in ("load_in_4bit", "load_in_8bit", "_load_in_4bit", "_load_in_8bit")
170
+ ):
171
+ bits = "4-bit" if quant_config.get("load_in_4bit", quant_config.get("_load_in_4bit")) else "8-bit"
172
+ return "bitsandbytes", f"bitsandbytes {bits}"
173
+ if "awq" in quant_method or "awq" in raw:
174
+ return "awq", "AWQ"
175
+ if "gptq" in quant_method or "gptq" in raw:
176
+ return "gptq", "GPTQ"
177
+ if "compressed" in quant_method or "compressed-tensors" in raw:
178
+ if "fp8" in raw or "float8" in raw or "nvfp4" in raw:
179
+ return "compressed-tensors", "compressed-tensors / FP8 or FP4"
180
+ return "compressed-tensors", "compressed-tensors"
181
+ if "fp8" in quant_method or "float8" in raw or "float8" in model_text:
182
+ return "fp8", "FP8"
183
+ return "none", None
184
+
185
+
186
+ def _quantization_from_filenames(files: Iterable[str]) -> tuple[str, str | None]:
187
+ text = " ".join(files).lower()
188
+ if "bitsandbytes" in text or "bnb" in text:
189
+ if "4bit" in text or "4-bit" in text:
190
+ return "bitsandbytes", "bitsandbytes 4-bit (filename heuristic)"
191
+ if "8bit" in text or "8-bit" in text:
192
+ return "bitsandbytes", "bitsandbytes 8-bit (filename heuristic)"
193
+ if "compressed-tensors" in text or "compressed_tensors" in text:
194
+ return "compressed-tensors", "compressed-tensors (filename heuristic)"
195
+ if "gptq" in text:
196
+ return "gptq", "GPTQ (filename heuristic)"
197
+ if "awq" in text:
198
+ return "awq", "AWQ (filename heuristic)"
199
+ if "fp8" in text or "float8" in text or "nvfp4" in text:
200
+ return "fp8", "FP8 / FP4 (filename heuristic)"
201
+ return "none", None
202
+
203
+
204
+ def _dtype_label(config: dict[str, Any]) -> str:
205
+ value = str(config.get("torch_dtype") or config.get("dtype") or "").lower()
206
+ if "bfloat16" in value or value == "bf16":
207
+ return "BF16"
208
+ if "float16" in value or value in {"fp16", "half"}:
209
+ return "FP16"
210
+ if "float8" in value or "fp8" in value:
211
+ return "FP8"
212
+ if "float32" in value or value == "fp32":
213
+ return "FP32"
214
+ return "dtype auto"
215
+
216
+
217
+ def inspection_from_files(
218
+ model_id: str,
219
+ files: Iterable[str],
220
+ config: dict[str, Any] | None = None,
221
+ source: str = "remote",
222
+ ) -> ModelInspection:
223
+ """Build an inspection from a file list and an optional config."""
224
+
225
+ file_list = sorted(set(str(name) for name in files))
226
+ gguf_files = sorted(name for name in file_list if _is_gguf(name))
227
+ config = config or {}
228
+ has_weights = _has_standard_weights(file_list)
229
+
230
+ if gguf_files:
231
+ quantizations = ModelInspection(
232
+ model_id=model_id,
233
+ files=file_list,
234
+ gguf_files=gguf_files,
235
+ ).gguf_quantizations
236
+ quant_label = ", ".join(quantizations) if quantizations else "quantized"
237
+ format_label = f"GGUF / {quant_label}"
238
+ return ModelInspection(
239
+ model_id=model_id,
240
+ files=file_list,
241
+ gguf_files=gguf_files,
242
+ has_standard_weights=has_weights,
243
+ quantization_kind="gguf",
244
+ format_label=format_label,
245
+ preferred_backend=BACKEND_LLAMACPP,
246
+ config=config,
247
+ source=source,
248
+ )
249
+
250
+ kind, label = _quantization_from_config(config)
251
+ if kind == "none":
252
+ kind, label = _quantization_from_filenames(file_list)
253
+ if label is None:
254
+ label = f"safetensors / {_dtype_label(config)}" if has_weights else "Unknown"
255
+
256
+ return ModelInspection(
257
+ model_id=model_id,
258
+ files=file_list,
259
+ gguf_files=[],
260
+ has_standard_weights=has_weights,
261
+ quantization_kind=kind,
262
+ format_label=label,
263
+ preferred_backend=BACKEND_TRANSFORMERS,
264
+ config=config,
265
+ source=source,
266
+ )
267
+
268
+
269
+ class BackendRouter:
270
+ """Inspect Hub repositories and resolve an explicit runtime backend."""
271
+
272
+ def __init__(self, api: HfApi | None = None) -> None:
273
+ self.api = api or HfApi()
274
+
275
+ def inspect_remote(self, model_id: str, cache_dir: str | Path | None = None) -> ModelInspection:
276
+ files = list(self.api.list_repo_files(repo_id=model_id, repo_type="model"))
277
+ gguf_files = [name for name in files if _is_gguf(name)]
278
+ config: dict[str, Any] = {}
279
+
280
+ # GGUF contains its own architecture/template metadata. Avoid even
281
+ # fetching config.json for a GGUF-only repository; the selected GGUF
282
+ # is the only large artifact downloaded later.
283
+ if not gguf_files or _has_standard_weights(files):
284
+ try:
285
+ config_path = hf_hub_download(
286
+ repo_id=model_id,
287
+ filename="config.json",
288
+ repo_type="model",
289
+ cache_dir=str(cache_dir) if cache_dir else None,
290
+ )
291
+ config = _read_json(Path(config_path))
292
+ except Exception:
293
+ config = {}
294
+
295
+ return inspection_from_files(
296
+ model_id=model_id,
297
+ files=files,
298
+ config=config,
299
+ source="remote",
300
+ )
301
+
302
+ def inspect_snapshot(self, model_id: str, snapshot_path: str | Path) -> ModelInspection:
303
+ root = Path(snapshot_path)
304
+ files = [str(path.relative_to(root)) for path in root.rglob("*") if path.is_file()]
305
+ return inspection_from_files(
306
+ model_id=model_id,
307
+ files=files,
308
+ config=_read_json(root / "config.json"),
309
+ source="cache",
310
+ )
311
+
312
+ @staticmethod
313
+ def synthetic_gguf(model_id: str, filename: str) -> ModelInspection:
314
+ return inspection_from_files(
315
+ model_id=model_id,
316
+ files=[filename],
317
+ config={},
318
+ source="cache",
319
+ )
320
+
321
+ @staticmethod
322
+ def _normalize_backend(value: str | None) -> str:
323
+ normalized = (value or BACKEND_AUTO).strip().lower()
324
+ if normalized in {"auto", "automatic"}:
325
+ return BACKEND_AUTO
326
+ if normalized in {"transformers", "transformer"}:
327
+ return BACKEND_TRANSFORMERS
328
+ if normalized in {"llama.cpp", "llama-cpp", "llamacpp", "llama"}:
329
+ return BACKEND_LLAMACPP
330
+ raise BackendRouterError(
331
+ f"Unknown backend `{value}`. Choose Auto, Transformers, or llama.cpp."
332
+ )
333
+
334
+ def resolve_backend(
335
+ self,
336
+ inspection: ModelInspection,
337
+ requested_backend: str | None = BACKEND_AUTO,
338
+ selected_file: str | None = None,
339
+ ) -> str:
340
+ requested = self._normalize_backend(requested_backend)
341
+ selected = (selected_file or "").strip()
342
+ selected_is_gguf = bool(selected) and _is_gguf(selected)
343
+
344
+ if selected and selected not in inspection.gguf_files:
345
+ raise BackendRouterError(
346
+ f"`{selected}` is not one of the GGUF files detected in `{inspection.model_id}`."
347
+ )
348
+
349
+ if requested == BACKEND_AUTO:
350
+ if selected_is_gguf:
351
+ return BACKEND_LLAMACPP
352
+ if inspection.is_gguf and not inspection.has_standard_weights:
353
+ raise BackendRouterError(
354
+ "This is a GGUF repository. Select a `.gguf` quant file before downloading or loading it."
355
+ )
356
+ return BACKEND_TRANSFORMERS
357
+
358
+ if requested == BACKEND_LLAMACPP:
359
+ if not selected_is_gguf:
360
+ raise BackendRouterError(
361
+ "llama.cpp requires a selected `.gguf` file. Inspect the repository and choose a quant."
362
+ )
363
+ return BACKEND_LLAMACPP
364
+
365
+ if selected_is_gguf:
366
+ raise BackendRouterError(
367
+ "Transformers cannot load a GGUF file here; choose llama.cpp or select Transformers weights."
368
+ )
369
+ if not inspection.has_standard_weights:
370
+ raise BackendRouterError(
371
+ "No standard Transformers weight file was found. This repository needs a GGUF file and llama.cpp."
372
+ )
373
+ return BACKEND_TRANSFORMERS
374
+
model_manager.py CHANGED
@@ -1,14 +1,8 @@
1
- """CPU cache helpers and the single-model Transformers runtime.
2
-
3
- The runtime deliberately keeps model loading behind the GPU handlers in
4
- ``app.py``. That makes model IDs dynamic while still ensuring that no model
5
- weights are loaded during Space startup.
6
- """
7
 
8
  from __future__ import annotations
9
 
10
  import gc
11
- import json
12
  import logging
13
  import os
14
  import re
@@ -16,19 +10,29 @@ import threading
16
  from pathlib import Path
17
  from typing import Any
18
 
 
 
19
  import spaces
20
  import torch
21
- from huggingface_hub import scan_cache_dir, snapshot_download
22
  from transformers import AutoModelForCausalLM, AutoTokenizer
23
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  LOGGER = logging.getLogger(__name__)
26
  MODEL_ID_PATTERN = re.compile(r"^[^/\s]+/[^/\s]+$")
27
 
28
 
29
  def validate_model_id(model_id: str) -> str:
30
- """Validate and normalize a Hugging Face model repository ID."""
31
-
32
  normalized = (model_id or "").strip()
33
  if not MODEL_ID_PATTERN.fullmatch(normalized):
34
  raise ValueError("Model ID must look like namespace/model-name.")
@@ -36,92 +40,137 @@ def validate_model_id(model_id: str) -> str:
36
 
37
 
38
  class UnsupportedModelError(RuntimeError):
39
- """Raised when a repository is outside the MVP's Transformers LM scope."""
40
 
41
 
42
  class ModelCache:
43
- """A dedicated Hugging Face cache for downloaded model snapshots."""
44
 
45
  def __init__(self, cache_dir: str | None = None) -> None:
46
  default_dir = Path.home() / ".cache" / "huggingface" / "llm-playground"
47
  self.root = Path(cache_dir or os.getenv("PLAYGROUND_CACHE_DIR", default_dir))
48
  self.root.mkdir(parents=True, exist_ok=True)
 
 
 
 
49
 
50
  def download(self, model_id: str) -> Path:
51
- """Download a complete model snapshot without initializing a model."""
52
 
53
- model_id = validate_model_id(model_id)
54
- snapshot_path = snapshot_download(
55
- repo_id=model_id,
56
- repo_type="model",
57
- cache_dir=str(self.root),
 
58
  )
59
- return Path(snapshot_path)
60
 
61
- def cached_snapshot(self, model_id: str) -> Path:
62
- """Return a locally cached snapshot, raising if it is not complete."""
63
 
64
  model_id = validate_model_id(model_id)
65
- try:
66
- snapshot_path = snapshot_download(
 
 
 
 
 
 
 
 
67
  repo_id=model_id,
 
68
  repo_type="model",
69
  cache_dir=str(self.root),
70
- local_files_only=True,
71
  )
72
- except Exception as exc: # hub versions expose different local-cache errors
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  raise FileNotFoundError(
74
  f"{model_id} is not downloaded yet. Click Download first."
75
  ) from exc
76
- return Path(snapshot_path)
77
-
78
- def ensure_transformers_checkpoint(self, model_id: str) -> Path:
79
- """Validate that a cached repo looks like a standard Transformers LM."""
80
-
81
- snapshot_path = self.cached_snapshot(model_id)
82
- files = [path for path in snapshot_path.rglob("*") if path.is_file()]
83
- has_gguf = any(path.name.lower().endswith(".gguf") for path in files)
84
- has_standard_weights = any(
85
- path.name.lower().endswith((".safetensors", ".bin", ".pt", ".pth"))
86
- or path.name.lower() in {"model.safetensors.index.json", "pytorch_model.bin.index.json"}
87
- for path in files
88
- )
89
 
90
- if has_gguf and not has_standard_weights:
91
- raise UnsupportedModelError(
92
- "This Space currently supports standard Transformers checkpoints only. "
93
- "The selected repository contains GGUF/quantized files; use a repo with "
94
- "safetensors or PyTorch weights, or wait for a GGUF backend."
 
 
 
 
 
 
 
 
 
95
  )
 
 
 
 
96
 
97
- config_path = snapshot_path / "config.json"
98
- if config_path.is_file():
99
- try:
100
- config = json.loads(config_path.read_text(encoding="utf-8"))
101
- except (OSError, json.JSONDecodeError):
102
- config = {}
103
- if config.get("model_type") == "bit":
104
- raise UnsupportedModelError(
105
- "The selected repository declares model_type=bit, which is a vision "
106
- "backbone and not a causal language model."
107
- )
108
 
109
- return snapshot_path
 
 
 
 
 
 
 
110
 
111
- def describe(self, model_id: str) -> str:
112
- """Return a short cache status for the UI."""
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
 
114
  model_id = (model_id or "").strip()
115
  if not model_id:
116
  return "Disk cache: no model selected."
117
  try:
 
 
 
118
  path = self.cached_snapshot(model_id)
 
119
  except (ValueError, FileNotFoundError):
120
- return f"Disk cache: {model_id} is not downloaded."
121
- return f"Disk cache: ready ({path.name})."
122
 
123
  def delete(self, model_id: str) -> bool:
124
- """Delete every cached revision of one model from this cache."""
125
 
126
  model_id = validate_model_id(model_id)
127
  cache_info = scan_cache_dir(cache_dir=str(self.root))
@@ -129,81 +178,225 @@ class ModelCache:
129
  for repo in cache_info.repos:
130
  if repo.repo_id == model_id:
131
  revisions.extend(revision.commit_hash for revision in repo.revisions)
132
-
133
  if not revisions:
134
  return False
135
-
136
- # The cache manager removes snapshots, refs, and blobs that are no
137
- # longer shared by another cached revision.
138
  cache_info.delete_revisions(*revisions).execute()
139
  return True
140
 
141
 
142
- class TransformersCausalLMRuntime:
143
- """Single active standard Transformers causal language model."""
144
 
145
  def __init__(self, cache: ModelCache) -> None:
146
  self.cache = cache
147
  self._model: Any | None = None
148
  self._tokenizer: Any | None = None
 
149
  self._model_id: str | None = None
 
 
 
150
  self._lock = threading.RLock()
151
 
152
  @property
153
  def active_model_id(self) -> str | None:
154
  return self._model_id
155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  def unload(self) -> None:
157
- """Release the active model and clear CUDA's allocator cache."""
158
 
159
  with self._lock:
160
  old_model = self._model
 
161
  self._model = None
162
  self._tokenizer = None
 
163
  self._model_id = None
164
-
 
 
 
 
 
 
 
 
 
 
 
165
  if old_model is not None:
166
  del old_model
 
167
  gc.collect()
168
  if torch.cuda.is_available():
169
  torch.cuda.empty_cache()
170
 
171
- def ensure_loaded(self, model_id: str) -> str:
172
- """Load one cached model on CUDA, replacing any previously active model."""
 
 
 
 
 
173
 
174
  model_id = validate_model_id(model_id)
 
 
 
175
  with self._lock:
176
- snapshot_path = self.cache.ensure_transformers_checkpoint(model_id)
177
- if self._model is not None and self._model_id == model_id:
178
- return model_id
179
 
180
- # Switching models always releases the old object before reading
181
- # the new checkpoint, keeping the one-model invariant explicit.
182
  self.unload()
183
- tokenizer = AutoTokenizer.from_pretrained(
184
- str(snapshot_path),
185
- local_files_only=True,
186
- use_fast=True,
187
- trust_remote_code=False,
188
- )
189
- model = AutoModelForCausalLM.from_pretrained(
190
- str(snapshot_path),
191
- local_files_only=True,
192
- torch_dtype=torch.bfloat16,
193
- low_cpu_mem_usage=True,
194
- trust_remote_code=False,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  )
196
- model = model.to("cuda").eval()
 
 
 
 
 
 
 
 
 
 
 
197
 
198
- if tokenizer.pad_token_id is None and tokenizer.eos_token_id is not None:
199
- tokenizer.pad_token = tokenizer.eos_token
200
- if getattr(model.config, "pad_token_id", None) is None:
201
- model.config.pad_token_id = tokenizer.pad_token_id
 
 
 
 
 
 
 
 
 
 
202
 
203
- self._tokenizer = tokenizer
204
- self._model = model
205
- self._model_id = model_id
206
- return model_id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
 
208
  @staticmethod
209
  def _history_to_messages(history: list[Any] | None) -> list[dict[str, str]]:
@@ -224,13 +417,12 @@ class TransformersCausalLMRuntime:
224
 
225
  @staticmethod
226
  def _plain_prompt(messages: list[dict[str, str]]) -> str:
227
- lines = [f"{message['role'].capitalize()}: {message['content']}" for message in messages]
228
  return "\n".join(lines) + "\nAssistant:"
229
 
230
  def _tokenize(self, messages: list[dict[str, str]]) -> Any:
231
  assert self._tokenizer is not None
232
  tokenizer = self._tokenizer
233
-
234
  if hasattr(tokenizer, "apply_chat_template"):
235
  try:
236
  return tokenizer.apply_chat_template(
@@ -252,12 +444,13 @@ class TransformersCausalLMRuntime:
252
  LOGGER.debug("Chat template without return_dict failed", exc_info=True)
253
  except Exception:
254
  LOGGER.debug("Chat template failed; using plain prompt", exc_info=True)
255
-
256
  return tokenizer(self._plain_prompt(messages), return_tensors="pt")
257
 
258
  def generate(
259
  self,
260
  model_id: str,
 
 
261
  message: str,
262
  history: list[Any] | None,
263
  system_prompt: str,
@@ -265,19 +458,32 @@ class TransformersCausalLMRuntime:
265
  temperature: float,
266
  top_p: float,
267
  ) -> str:
268
- """Generate one answer from the active cached Transformers model."""
 
 
 
 
 
269
 
270
  with self._lock:
271
- self.ensure_loaded(model_id)
272
- assert self._model is not None
273
- assert self._tokenizer is not None
274
-
275
- messages: list[dict[str, str]] = []
276
- if (system_prompt or "").strip():
277
- messages.append({"role": "system", "content": system_prompt.strip()})
278
- messages.extend(self._history_to_messages(history))
279
- messages.append({"role": "user", "content": (message or "").strip()})
 
 
 
 
 
 
280
 
 
 
281
  encoded = self._tokenize(messages)
282
  encoded = {
283
  key: value.to("cuda")
@@ -285,17 +491,15 @@ class TransformersCausalLMRuntime:
285
  if torch.is_tensor(value)
286
  }
287
  input_length = int(encoded["input_ids"].shape[-1])
288
-
289
  generation_kwargs: dict[str, Any] = {
290
  "max_new_tokens": max_new_tokens,
291
  "do_sample": temperature > 0,
292
  }
293
  if temperature > 0:
294
  generation_kwargs.update({"temperature": temperature, "top_p": top_p})
295
-
296
  with torch.inference_mode():
297
  generated = self._model.generate(**encoded, **generation_kwargs)
298
-
299
  new_tokens = generated[0, input_length:]
300
  answer = self._tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
301
  return answer or "The model returned an empty response."
 
 
1
+ """Cache management and single-model runtimes for the ZeroGPU playground."""
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
  import gc
 
6
  import logging
7
  import os
8
  import re
 
10
  from pathlib import Path
11
  from typing import Any
12
 
13
+ # ZeroGPU must be imported before torch. The lazy llama.cpp import below is
14
+ # also intentionally kept inside the GPU-side loader.
15
  import spaces
16
  import torch
17
+ from huggingface_hub import hf_hub_download, scan_cache_dir, snapshot_download
18
  from transformers import AutoModelForCausalLM, AutoTokenizer
19
 
20
+ from backend_router import (
21
+ BACKEND_AUTO,
22
+ BACKEND_LLAMACPP,
23
+ BACKEND_TRANSFORMERS,
24
+ BackendRouter,
25
+ BackendRouterError,
26
+ ModelInspection,
27
+ ResolvedBackend,
28
+ )
29
+
30
 
31
  LOGGER = logging.getLogger(__name__)
32
  MODEL_ID_PATTERN = re.compile(r"^[^/\s]+/[^/\s]+$")
33
 
34
 
35
  def validate_model_id(model_id: str) -> str:
 
 
36
  normalized = (model_id or "").strip()
37
  if not MODEL_ID_PATTERN.fullmatch(normalized):
38
  raise ValueError("Model ID must look like namespace/model-name.")
 
40
 
41
 
42
  class UnsupportedModelError(RuntimeError):
43
+ """Compatibility alias for callers that want a user-facing load error."""
44
 
45
 
46
  class ModelCache:
47
+ """Keep standard snapshots and individually selected GGUF files in one cache."""
48
 
49
  def __init__(self, cache_dir: str | None = None) -> None:
50
  default_dir = Path.home() / ".cache" / "huggingface" / "llm-playground"
51
  self.root = Path(cache_dir or os.getenv("PLAYGROUND_CACHE_DIR", default_dir))
52
  self.root.mkdir(parents=True, exist_ok=True)
53
+ self.router = BackendRouter()
54
+
55
+ def inspect_remote(self, model_id: str) -> ModelInspection:
56
+ return self.router.inspect_remote(validate_model_id(model_id), cache_dir=self.root)
57
 
58
  def download(self, model_id: str) -> Path:
59
+ """Download a standard Transformers snapshot on CPU."""
60
 
61
+ return Path(
62
+ snapshot_download(
63
+ repo_id=validate_model_id(model_id),
64
+ repo_type="model",
65
+ cache_dir=str(self.root),
66
+ )
67
  )
 
68
 
69
+ def download_gguf(self, model_id: str, filename: str) -> Path:
70
+ """Download exactly one GGUF file, never the whole repository."""
71
 
72
  model_id = validate_model_id(model_id)
73
+ filename = (filename or "").strip()
74
+ if not filename or not filename.lower().endswith(".gguf"):
75
+ raise ValueError("Choose one `.gguf` file before downloading.")
76
+
77
+ inspection = self.inspect_remote(model_id)
78
+ if filename not in inspection.gguf_files:
79
+ raise ValueError(f"`{filename}` is not a GGUF file in `{model_id}`.")
80
+
81
+ return Path(
82
+ hf_hub_download(
83
  repo_id=model_id,
84
+ filename=filename,
85
  repo_type="model",
86
  cache_dir=str(self.root),
 
87
  )
88
+ )
89
+
90
+ def cached_snapshot(self, model_id: str) -> Path:
91
+ model_id = validate_model_id(model_id)
92
+ try:
93
+ return Path(
94
+ snapshot_download(
95
+ repo_id=model_id,
96
+ repo_type="model",
97
+ cache_dir=str(self.root),
98
+ local_files_only=True,
99
+ )
100
+ )
101
+ except Exception as exc:
102
  raise FileNotFoundError(
103
  f"{model_id} is not downloaded yet. Click Download first."
104
  ) from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
+ def cached_gguf(self, model_id: str, filename: str) -> Path:
107
+ model_id = validate_model_id(model_id)
108
+ filename = (filename or "").strip()
109
+ if not filename.lower().endswith(".gguf"):
110
+ raise ValueError("Choose one `.gguf` file before loading.")
111
+ try:
112
+ return Path(
113
+ hf_hub_download(
114
+ repo_id=model_id,
115
+ filename=filename,
116
+ repo_type="model",
117
+ cache_dir=str(self.root),
118
+ local_files_only=True,
119
+ )
120
  )
121
+ except Exception as exc:
122
+ raise FileNotFoundError(
123
+ f"`{filename}` is not downloaded yet. Click Download for this GGUF file first."
124
+ ) from exc
125
 
126
+ def resolve_cached(
127
+ self,
128
+ model_id: str,
129
+ requested_backend: str | None,
130
+ selected_file: str | None,
131
+ ) -> ResolvedBackend:
132
+ """Resolve a local artifact without doing network I/O on a GPU call."""
 
 
 
 
133
 
134
+ model_id = validate_model_id(model_id)
135
+ selected = (selected_file or "").strip()
136
+ if selected:
137
+ path = self.cached_gguf(model_id, selected)
138
+ inspection = self.router.synthetic_gguf(model_id, selected)
139
+ else:
140
+ path = self.cached_snapshot(model_id)
141
+ inspection = self.router.inspect_snapshot(model_id, path)
142
 
143
+ backend = self.router.resolve_backend(
144
+ inspection,
145
+ requested_backend=requested_backend,
146
+ selected_file=selected or None,
147
+ )
148
+ if backend == BACKEND_LLAMACPP and not selected:
149
+ raise BackendRouterError(
150
+ "Choose the GGUF file you downloaded before loading it with llama.cpp."
151
+ )
152
+ return ResolvedBackend(
153
+ backend=backend,
154
+ path=path,
155
+ inspection=inspection,
156
+ selected_file=selected or None,
157
+ )
158
 
159
+ def describe(self, model_id: str, selected_file: str | None = None) -> str:
160
  model_id = (model_id or "").strip()
161
  if not model_id:
162
  return "Disk cache: no model selected."
163
  try:
164
+ if selected_file:
165
+ path = self.cached_gguf(model_id, selected_file)
166
+ return f"Disk cache: GGUF ready (`{path.name}`)."
167
  path = self.cached_snapshot(model_id)
168
+ return f"Disk cache: snapshot ready (`{path.name}`)."
169
  except (ValueError, FileNotFoundError):
170
+ return f"Disk cache: `{model_id}` is not downloaded."
 
171
 
172
  def delete(self, model_id: str) -> bool:
173
+ """Remove all cached revisions for one model, including selected GGUFs."""
174
 
175
  model_id = validate_model_id(model_id)
176
  cache_info = scan_cache_dir(cache_dir=str(self.root))
 
178
  for repo in cache_info.repos:
179
  if repo.repo_id == model_id:
180
  revisions.extend(revision.commit_hash for revision in repo.revisions)
 
181
  if not revisions:
182
  return False
 
 
 
183
  cache_info.delete_revisions(*revisions).execute()
184
  return True
185
 
186
 
187
+ class ModelRuntime:
188
+ """Route one active model to Transformers or llama.cpp."""
189
 
190
  def __init__(self, cache: ModelCache) -> None:
191
  self.cache = cache
192
  self._model: Any | None = None
193
  self._tokenizer: Any | None = None
194
+ self._llama: Any | None = None
195
  self._model_id: str | None = None
196
+ self._backend: str | None = None
197
+ self._selected_file: str | None = None
198
+ self._inspection: ModelInspection | None = None
199
  self._lock = threading.RLock()
200
 
201
  @property
202
  def active_model_id(self) -> str | None:
203
  return self._model_id
204
 
205
+ @property
206
+ def active_backend(self) -> str | None:
207
+ return self._backend
208
+
209
+ @property
210
+ def active_selected_file(self) -> str | None:
211
+ return self._selected_file
212
+
213
+ @property
214
+ def active_inspection(self) -> ModelInspection | None:
215
+ return self._inspection
216
+
217
+ def active_label(self) -> str:
218
+ if not self._model_id:
219
+ return "No model loaded"
220
+ suffix = f" · {self._backend}"
221
+ if self._selected_file:
222
+ suffix += f" · {self._selected_file}"
223
+ return f"{self._model_id}{suffix}"
224
+
225
  def unload(self) -> None:
226
+ """Release both possible runtimes and clear CUDA allocator state."""
227
 
228
  with self._lock:
229
  old_model = self._model
230
+ old_llama = self._llama
231
  self._model = None
232
  self._tokenizer = None
233
+ self._llama = None
234
  self._model_id = None
235
+ self._backend = None
236
+ self._selected_file = None
237
+ self._inspection = None
238
+
239
+ if old_llama is not None:
240
+ close = getattr(old_llama, "close", None)
241
+ if callable(close):
242
+ try:
243
+ close()
244
+ except Exception:
245
+ LOGGER.debug("llama.cpp close failed during cleanup", exc_info=True)
246
+ del old_llama
247
  if old_model is not None:
248
  del old_model
249
+
250
  gc.collect()
251
  if torch.cuda.is_available():
252
  torch.cuda.empty_cache()
253
 
254
+ def ensure_loaded(
255
+ self,
256
+ model_id: str,
257
+ requested_backend: str | None = BACKEND_AUTO,
258
+ selected_file: str | None = None,
259
+ ) -> ResolvedBackend:
260
+ """Load one local artifact on GPU, replacing any active model."""
261
 
262
  model_id = validate_model_id(model_id)
263
+ target = self.cache.resolve_cached(model_id, requested_backend, selected_file)
264
+ identity = (model_id, target.backend, target.selected_file)
265
+
266
  with self._lock:
267
+ current = (self._model_id, self._backend, self._selected_file)
268
+ if current == identity and (self._model is not None or self._llama is not None):
269
+ return target
270
 
271
+ # The one-model invariant is enforced before constructing either
272
+ # a new Transformers model or a new llama.cpp context.
273
  self.unload()
274
+ if target.backend == BACKEND_LLAMACPP:
275
+ self._load_llama(target)
276
+ else:
277
+ self._load_transformers(target)
278
+
279
+ self._model_id = model_id
280
+ self._backend = target.backend
281
+ self._selected_file = target.selected_file
282
+ self._inspection = target.inspection
283
+ return target
284
+
285
+ @staticmethod
286
+ def _preferred_dtype(inspection: ModelInspection) -> Any:
287
+ value = str(
288
+ inspection.config.get("torch_dtype") or inspection.config.get("dtype") or ""
289
+ ).lower()
290
+ if "float16" in value or value in {"fp16", "half"}:
291
+ return torch.float16
292
+ if "float32" in value or value == "fp32":
293
+ return torch.float32
294
+ if "float8" in value or "fp8" in value:
295
+ return "auto"
296
+ return torch.bfloat16
297
+
298
+ @staticmethod
299
+ def _bnb_config(inspection: ModelInspection) -> Any | None:
300
+ """Create a BitsAndBytesConfig only for filename-only quant repos."""
301
+
302
+ quant_config = inspection.config.get("quantization_config")
303
+ if isinstance(quant_config, dict) and any(
304
+ key in quant_config
305
+ for key in ("load_in_4bit", "load_in_8bit", "_load_in_4bit", "_load_in_8bit")
306
+ ):
307
+ return None # Transformers will consume the repository config itself.
308
+
309
+ if inspection.quantization_kind != "bitsandbytes":
310
+ return None
311
+ try:
312
+ from transformers import BitsAndBytesConfig
313
+
314
+ text = inspection.format_label.lower()
315
+ load_in_8bit = "8-bit" in text
316
+ return BitsAndBytesConfig(
317
+ load_in_4bit=not load_in_8bit,
318
+ load_in_8bit=load_in_8bit,
319
+ bnb_4bit_compute_dtype=torch.bfloat16,
320
+ bnb_4bit_quant_type="nf4",
321
+ bnb_4bit_use_double_quant=True,
322
  )
323
+ except Exception:
324
+ LOGGER.debug("Could not construct a BitsAndBytesConfig", exc_info=True)
325
+ return None
326
+
327
+ def _load_transformers(self, target: ResolvedBackend) -> None:
328
+ inspection = target.inspection
329
+ tokenizer = AutoTokenizer.from_pretrained(
330
+ str(target.path),
331
+ local_files_only=True,
332
+ use_fast=True,
333
+ trust_remote_code=False,
334
+ )
335
 
336
+ quantized = inspection.is_transformers_quantized
337
+ load_kwargs: dict[str, Any] = {
338
+ "local_files_only": True,
339
+ "low_cpu_mem_usage": True,
340
+ "trust_remote_code": False,
341
+ "dtype": "auto" if quantized else self._preferred_dtype(inspection),
342
+ }
343
+ if quantized:
344
+ # Quantized modules must be placed by Accelerate/Transformers and
345
+ # must not receive a later blanket `.to("cuda")` call.
346
+ load_kwargs["device_map"] = "cuda"
347
+ bnb_config = self._bnb_config(inspection)
348
+ if bnb_config is not None:
349
+ load_kwargs["quantization_config"] = bnb_config
350
 
351
+ try:
352
+ model = AutoModelForCausalLM.from_pretrained(str(target.path), **load_kwargs)
353
+ if not quantized:
354
+ model = model.to("cuda")
355
+ model = model.eval()
356
+ except Exception as exc:
357
+ label = inspection.format_label
358
+ raise RuntimeError(
359
+ f"Could not load `{label}` with Transformers. "
360
+ "The repository's quantization runtime may need a compatible loader package. "
361
+ f"Details: {str(exc).splitlines()[0][:260]}"
362
+ ) from exc
363
+
364
+ if tokenizer.pad_token_id is None and tokenizer.eos_token_id is not None:
365
+ tokenizer.pad_token = tokenizer.eos_token
366
+ if getattr(model.config, "pad_token_id", None) is None:
367
+ model.config.pad_token_id = tokenizer.pad_token_id
368
+
369
+ self._tokenizer = tokenizer
370
+ self._model = model
371
+
372
+ def _load_llama(self, target: ResolvedBackend) -> None:
373
+ try:
374
+ from llama_cpp import Llama
375
+ except Exception as exc:
376
+ raise RuntimeError(
377
+ "llama.cpp is not available. The Space needs the CUDA-enabled llama-cpp-python wheel."
378
+ ) from exc
379
+
380
+ kwargs: dict[str, Any] = {
381
+ "model_path": str(target.path),
382
+ "n_gpu_layers": -1,
383
+ "n_ctx": 8192,
384
+ "n_batch": 512,
385
+ "n_threads": max(2, min(8, os.cpu_count() or 4)),
386
+ "verbose": False,
387
+ }
388
+ try:
389
+ llama = Llama(**kwargs, flash_attn=True)
390
+ except TypeError:
391
+ # Keep compatibility with older wheels that predate flash_attn in
392
+ # the Python constructor; the GPU offload remains explicit.
393
+ llama = Llama(**kwargs)
394
+ except Exception as exc:
395
+ raise RuntimeError(
396
+ f"Could not load `{target.selected_file}` with llama.cpp GPU offload. "
397
+ f"Details: {str(exc).splitlines()[0][:260]}"
398
+ ) from exc
399
+ self._llama = llama
400
 
401
  @staticmethod
402
  def _history_to_messages(history: list[Any] | None) -> list[dict[str, str]]:
 
417
 
418
  @staticmethod
419
  def _plain_prompt(messages: list[dict[str, str]]) -> str:
420
+ lines = [f"{m['role'].capitalize()}: {m['content']}" for m in messages]
421
  return "\n".join(lines) + "\nAssistant:"
422
 
423
  def _tokenize(self, messages: list[dict[str, str]]) -> Any:
424
  assert self._tokenizer is not None
425
  tokenizer = self._tokenizer
 
426
  if hasattr(tokenizer, "apply_chat_template"):
427
  try:
428
  return tokenizer.apply_chat_template(
 
444
  LOGGER.debug("Chat template without return_dict failed", exc_info=True)
445
  except Exception:
446
  LOGGER.debug("Chat template failed; using plain prompt", exc_info=True)
 
447
  return tokenizer(self._plain_prompt(messages), return_tensors="pt")
448
 
449
  def generate(
450
  self,
451
  model_id: str,
452
+ requested_backend: str | None,
453
+ selected_file: str | None,
454
  message: str,
455
  history: list[Any] | None,
456
  system_prompt: str,
 
458
  temperature: float,
459
  top_p: float,
460
  ) -> str:
461
+ target = self.ensure_loaded(model_id, requested_backend, selected_file)
462
+ messages: list[dict[str, str]] = []
463
+ if (system_prompt or "").strip():
464
+ messages.append({"role": "system", "content": system_prompt.strip()})
465
+ messages.extend(self._history_to_messages(history))
466
+ messages.append({"role": "user", "content": (message or "").strip()})
467
 
468
  with self._lock:
469
+ if target.backend == BACKEND_LLAMACPP:
470
+ if self._llama is None:
471
+ raise RuntimeError("llama.cpp runtime is not loaded.")
472
+ result = self._llama.create_chat_completion(
473
+ messages=messages,
474
+ max_tokens=max_new_tokens,
475
+ temperature=temperature,
476
+ top_p=top_p,
477
+ )
478
+ answer = ""
479
+ if isinstance(result, dict) and result.get("choices"):
480
+ answer = str(
481
+ result["choices"][0].get("message", {}).get("content", "")
482
+ )
483
+ return answer.strip() or "The model returned an empty response."
484
 
485
+ if self._model is None or self._tokenizer is None:
486
+ raise RuntimeError("Transformers runtime is not loaded.")
487
  encoded = self._tokenize(messages)
488
  encoded = {
489
  key: value.to("cuda")
 
491
  if torch.is_tensor(value)
492
  }
493
  input_length = int(encoded["input_ids"].shape[-1])
 
494
  generation_kwargs: dict[str, Any] = {
495
  "max_new_tokens": max_new_tokens,
496
  "do_sample": temperature > 0,
497
  }
498
  if temperature > 0:
499
  generation_kwargs.update({"temperature": temperature, "top_p": top_p})
 
500
  with torch.inference_mode():
501
  generated = self._model.generate(**encoded, **generation_kwargs)
 
502
  new_tokens = generated[0, input_length:]
503
  answer = self._tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
504
  return answer or "The model returned an empty response."
505
+
requirements.txt CHANGED
@@ -1,4 +1,9 @@
1
  transformers==4.57.6
2
- accelerate>=0.34.0
3
  safetensors>=0.4.3
4
  sentencepiece>=0.2.0
 
 
 
 
 
 
1
  transformers==4.57.6
2
+ accelerate>=1.1.0
3
  safetensors>=0.4.3
4
  sentencepiece>=0.2.0
5
+ bitsandbytes>=0.50.0
6
+ compressed-tensors>=0.18.0
7
+ autoawq==0.2.9
8
+ gptqmodel==2.2.0
9
+ llama-cpp-python @ https://github.com/abetlen/llama-cpp-python/releases/download/v0.3.35-cu130/llama_cpp_python-0.3.35-py3-none-manylinux_2_35_x86_64.whl