Chris4K commited on
Commit
16ebcba
Β·
verified Β·
1 Parent(s): f0f948d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +337 -281
app.py CHANGED
@@ -1,8 +1,11 @@
1
  """
2
- MiniCPM Forge β€” Unified Edge AI Showcase
3
- Five tiny models, one interface. llama.cpp-powered.
 
 
 
4
 
5
- build-small-hackathon 2026 Β· Chris4K Β· ki-fusion-labs.de
6
  """
7
  from __future__ import annotations
8
 
@@ -11,42 +14,35 @@ import base64
11
  import json
12
  import os
13
  import pathlib
 
14
  import threading
15
  import time
16
  import uuid
17
  from io import BytesIO
18
- from typing import Optional
19
 
20
  from PIL import Image
21
  from fastapi import Request
22
  from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
23
  from gradio import Server
24
- from huggingface_hub import hf_hub_download
25
 
26
  # ─────────────────────────────────────────────────────────────────────────────
27
- # ZeroGPU / HF Spaces GPU decorator (optional)
28
  # ─────────────────────────────────────────────────────────────────────────────
29
  try:
30
  import spaces # type: ignore
 
31
  except ImportError:
32
- class _NoSpaces:
 
33
  @staticmethod
34
  def GPU(duration: int = 120):
35
- def _wrap(fn):
36
- return fn
37
  return _wrap
38
- spaces = _NoSpaces() # type: ignore
39
 
40
  # ─────────────────────────────────────────────────────────────────────────────
41
- # Model registry (filenames verified June 2026 against HF repo trees)
42
- # ─────────────────────────────────────────────────────────────────────────────
43
- # Naming quirk: openbmb GGUF repos replace dots with underscores in version
44
- # numbers: 4.6 β†’ 4_6 in filenames, but the repo slug itself keeps dots.
45
- #
46
- # Confirmed sources:
47
- # LM : https://github.com/OpenBMB/MiniCPM-V-Apps (official mobile app)
48
- # mmproj: same + HF llama-cpp-python auto-snippet
49
- # CPM4.1-8B: openbmb/MiniCPM4.1-8B model card llama.cpp section
50
  # ─────────────────────────────────────────────────────────────────────────────
51
  MODELS: dict[str, dict] = {
52
  "v46": {
@@ -54,286 +50,304 @@ MODELS: dict[str, dict] = {
54
  "name": "MiniCPM-V 4.6",
55
  "tag": "Vision Β· OCR",
56
  "color": "#06b6d4",
57
- "repo": "openbmb/MiniCPM-V-4.6-gguf",
58
- "file": "MiniCPM-V-4_6-Q4_K_M.gguf", # underscore: 4_6, NOT 4.6
59
- "mmproj": "mmproj-model-f16.gguf", # short name, no model prefix
60
  "ctx": 8192,
61
  "vision": True,
62
- "thinking": False,
63
- "handler": "minicpmv",
64
  },
65
  "v46t": {
66
  "id": "v46t",
67
  "name": "MiniCPM-V 4.6-T",
68
  "tag": "Vision Β· Thinking",
69
  "color": "#a855f7",
70
- "repo": "openbmb/MiniCPM-V-4.6-Thinking-gguf",
71
- "file": "MiniCPM-V-4_6-Thinking-Q4_K_M.gguf", # same underscore convention
72
- "mmproj": "mmproj-model-f16.gguf",
73
  "ctx": 8192,
74
  "vision": True,
75
- "thinking": True,
76
- "handler": "minicpmv",
77
  },
78
  "cpm5": {
79
  "id": "cpm5",
80
  "name": "MiniCPM5-1B",
81
  "tag": "⚑ Ultra-light",
82
  "color": "#22c55e",
 
83
  "repo": "openbmb/MiniCPM5-1B-GGUF",
84
- "file": "MiniCPM5-1B-Q4_K_M.gguf", # βœ“ confirmed working
85
  "ctx": 4096,
86
  "vision": False,
87
  "thinking": False,
88
- "handler": "text",
89
  },
90
  "cpm41": {
91
  "id": "cpm41",
92
  "name": "MiniCPM4.1-8B",
93
  "tag": "🧠 Reasoning",
94
  "color": "#f97316",
95
- "repo": "openbmb/MiniCPM4.1-8B-GGUF", # .1 in repo slug
96
- "file": "MiniCPM4.1-8B-Q4_K_M.gguf", # .1 in filename (dots kept here)
 
97
  "ctx": 16384,
98
  "vision": False,
99
  "thinking": True,
100
- "handler": "text",
101
  },
102
  "o45": {
103
  "id": "o45",
104
  "name": "MiniCPM-o 4.5",
105
  "tag": "🌐 Omni",
106
  "color": "#ec4899",
107
- "api": True,
108
  "ctx": 8192,
109
  "vision": True,
110
  "thinking": False,
111
- "handler": "api",
112
- "note": "Served via OpenBMB API β€” no local download required",
113
  },
114
  }
115
 
116
  # ─────────────────────────────────────────────────────────────────────────────
117
- # Model loading β€” lazy, thread-safe, cached
118
  # ─────────────────────────────────────────────────────────────────────────────
119
  CACHE_DIR = pathlib.Path(os.environ.get("HF_HOME", "/tmp/hf_cache")) / "forge"
120
- _loaded: dict[str, object] = {}
121
  _load_lock = threading.Lock()
122
- _load_status: dict[str, str] = {} # model_id β†’ "idle"|"loading"|"ready"|"error:<msg>"
123
 
 
 
124
 
125
- def _get_vision_handler(mmproj_path: str):
126
- """Return the best available llama-cpp vision handler for MiniCPM-V 4.6.
 
127
 
128
- llama-cpp-python >= 0.3.9 ships MiniCPMv2_6ChatHandler which is also
129
- compatible with 4.6 at the GGUF level (the new merger architecture is
130
- abstracted away internally by llama.cpp >= b9049).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  """
132
- try:
133
- from llama_cpp.llama_chat_format import MiniCPMv2_6ChatHandler # type: ignore
134
- return MiniCPMv2_6ChatHandler(clip_model_path=mmproj_path)
135
- except (ImportError, AttributeError):
136
- pass
137
- try:
138
- from llama_cpp.llama_chat_format import Llava16ChatHandler # type: ignore
139
- return Llava16ChatHandler(clip_model_path=mmproj_path)
140
- except (ImportError, AttributeError):
141
- pass
142
- raise RuntimeError(
143
- "No compatible vision chat handler found. "
144
- "Run: pip install 'llama-cpp-python>=0.3.9'"
 
 
145
  )
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
- def _hub_download_with_glob(
149
- repo_id: str,
150
- filename: str,
151
- local_dir: str,
152
- ) -> str:
153
- """Download filename from repo_id.
 
 
 
 
154
 
155
- If the exact filename 404s, falls back to glob-matching the repo file list
156
- so minor naming inconsistencies across openbmb releases don't break things.
157
- """
158
- import fnmatch
159
- from huggingface_hub import list_repo_files # type: ignore
160
 
161
- local_dir_path = pathlib.Path(local_dir)
162
- local_dir_path.mkdir(parents=True, exist_ok=True)
163
 
164
- # 1) Try exact name first
165
- try:
166
- return hf_hub_download(repo_id=repo_id, filename=filename,
167
- local_dir=local_dir)
168
- except Exception as exact_err:
169
- pass # fall through to glob
170
-
171
- # 2) Build a glob from the filename: replace version numbers with *
172
- # e.g. MiniCPM-V-4_6-Q4_K_M.gguf β†’ *Q4_K_M*.gguf
173
- import re
174
- base = pathlib.Path(filename).name
175
- # Keep quant type suffix as anchor
176
- quant_match = re.search(r'(Q\d_K_[MS]|Q\d_\d|F16|BF16)', base)
177
- glob_pattern = f"*{quant_match.group(1)}*.gguf" if quant_match else f"*{base}*"
178
-
179
- all_files = list(list_repo_files(repo_id))
180
- candidates = [f for f in all_files if fnmatch.fnmatch(f, glob_pattern)
181
- and not f.endswith(".md") and not f.endswith(".json")]
182
 
183
- if not candidates:
184
- raise FileNotFoundError(
185
- f"Exact file {filename!r} not found in {repo_id}, "
186
- f"and glob {glob_pattern!r} matched nothing. "
187
- f"Available GGUF files: {[f for f in all_files if f.endswith('.gguf')]}"
188
- )
189
 
190
- # Prefer Q4_K_M over Q4_K_S etc if multiple hits
191
- best = next((f for f in candidates if "Q4_K_M" in f), candidates[0])
192
- print(f" β†’ glob fallback: using {best!r} instead of {filename!r}")
193
- return hf_hub_download(repo_id=repo_id, filename=best, local_dir=local_dir)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
 
 
 
195
 
196
- def _hub_download_mmproj(repo_id: str, mmproj_hint: str, local_dir: str) -> str:
197
- """Download mmproj file; glob-fallback to any *mmproj*f16*.gguf."""
198
  import fnmatch
199
- from huggingface_hub import list_repo_files # type: ignore
200
 
 
201
  try:
202
- return hf_hub_download(repo_id=repo_id, filename=mmproj_hint,
203
- local_dir=local_dir)
204
  except Exception:
205
  pass
206
-
207
- all_files = list(list_repo_files(repo_id))
208
- candidates = [f for f in all_files
209
- if "mmproj" in f.lower() and f.endswith(".gguf")
210
- and "iOS" not in f and "ios" not in f] # skip mobile-only variants
211
  if not candidates:
212
  raise FileNotFoundError(
213
- f"No mmproj file found in {repo_id}. "
214
- f"Files: {[f for f in all_files if f.endswith('.gguf')]}"
215
  )
216
- best = next((f for f in candidates if "f16" in f.lower()), candidates[0])
217
- print(f" β†’ mmproj glob fallback: {best!r}")
218
  return hf_hub_download(repo_id=repo_id, filename=best, local_dir=local_dir)
219
 
220
 
221
- def load_model(model_id: str) -> Optional[object]:
222
- """Download and load a GGUF model. Returns the Llama instance or None on error."""
223
- if model_id in _loaded:
224
- return _loaded[model_id]
225
-
226
- cfg = MODELS.get(model_id)
227
- if not cfg or cfg.get("api"):
228
- return None
229
-
230
  with _load_lock:
231
- if model_id in _loaded:
232
- return _loaded[model_id]
233
-
234
  _load_status[model_id] = "loading"
 
235
  local_dir = str(CACHE_DIR / model_id)
236
-
237
  try:
238
  from llama_cpp import Llama # type: ignore
239
 
240
- CACHE_DIR.mkdir(parents=True, exist_ok=True)
241
-
242
- print(f"[forge] Downloading LM: {cfg['repo']} / {cfg['file']}")
243
- model_path = _hub_download_with_glob(
244
- repo_id=cfg["repo"],
245
- filename=cfg["file"],
246
- local_dir=local_dir,
247
  )
248
-
249
- n_gpu_layers = int(os.environ.get("N_GPU_LAYERS", "-1"))
250
-
251
- if cfg.get("vision"):
252
- print(f"[forge] Downloading mmproj: {cfg['mmproj']}")
253
- mmproj_path = _hub_download_mmproj(
254
- repo_id=cfg["repo"],
255
- mmproj_hint=cfg["mmproj"],
256
- local_dir=local_dir,
257
- )
258
- chat_handler = _get_vision_handler(mmproj_path)
259
- llm = Llama(
260
- model_path=model_path,
261
- chat_handler=chat_handler,
262
- n_ctx=cfg["ctx"],
263
- n_gpu_layers=n_gpu_layers,
264
- verbose=False,
265
- )
266
- else:
267
- llm = Llama(
268
- model_path=model_path,
269
- n_ctx=cfg["ctx"],
270
- n_gpu_layers=n_gpu_layers,
271
- verbose=False,
272
- )
273
-
274
- _loaded[model_id] = llm
275
  _load_status[model_id] = "ready"
276
- print(f"[forge] βœ“ {model_id} ready")
277
- return llm
278
-
279
  except Exception as exc:
280
  _load_status[model_id] = f"error:{exc}"
281
- print(f"[forge] βœ— {model_id} failed: {exc}")
282
- return None
283
-
284
 
285
- # ─────────────────────────────────────────────────────────────────────────────
286
- # Inference helpers
287
- # ─────────────────────────────────────────────────────────────────────────────
288
-
289
- def _image_to_data_uri(b64_string: str) -> str:
290
- """Convert raw base64 image string to data: URI."""
291
- # Detect mime type from magic bytes
292
- raw = base64.b64decode(b64_string[:32])
293
- if raw[:8] == b"\x89PNG\r\n\x1a\n":
294
- mime = "image/png"
295
- elif raw[:3] == b"\xff\xd8\xff":
296
- mime = "image/jpeg"
297
- else:
298
- mime = "image/png"
299
- return f"data:{mime};base64,{b64_string}"
300
-
301
-
302
- def _build_messages(
303
- message: str,
304
- history: list,
305
- image_b64: Optional[str] = None,
306
- ) -> list[dict]:
307
- """Build OpenAI-style message list from chat history + current turn."""
308
- messages: list[dict] = []
309
-
310
- for turn in history or []:
311
- user_text = turn.get("user", "")
312
- asst_text = turn.get("assistant", "")
313
- if user_text:
314
- messages.append({"role": "user", "content": user_text})
315
- if asst_text:
316
- messages.append({"role": "assistant", "content": asst_text})
317
-
318
- # Current user turn β€” inject image if provided
319
- if image_b64:
320
- data_uri = _image_to_data_uri(image_b64)
321
- content: list[dict] = [
322
- {"type": "image_url", "image_url": {"url": data_uri}},
323
- {"type": "text", "text": message or "Describe this image."},
324
- ]
325
- messages.append({"role": "user", "content": content})
326
- else:
327
- messages.append({"role": "user", "content": message})
328
 
329
- return messages
 
 
 
 
 
 
 
330
 
331
-
332
- async def _stream_tokens(llm, messages: list, params: dict, loop: asyncio.AbstractEventLoop):
333
- """Async generator: yields tokens from a sync llama.cpp inference run."""
334
- queue: asyncio.Queue = asyncio.Queue(maxsize=128)
335
-
336
- def _run() -> None:
337
  try:
338
  output = llm.create_chat_completion(
339
  messages=messages,
@@ -341,7 +355,7 @@ async def _stream_tokens(llm, messages: list, params: dict, loop: asyncio.Abstra
341
  max_tokens=params.get("max_tokens", 1024),
342
  temperature=params.get("temperature", 0.7),
343
  top_p=params.get("top_p", 0.8),
344
- top_k=params.get("top_k", 100),
345
  repeat_penalty=params.get("repeat_penalty", 1.05),
346
  )
347
  for chunk in output:
@@ -349,11 +363,11 @@ async def _stream_tokens(llm, messages: list, params: dict, loop: asyncio.Abstra
349
  if delta:
350
  loop.call_soon_threadsafe(queue.put_nowait, delta)
351
  except Exception as exc:
352
- loop.call_soon_threadsafe(queue.put_nowait, f"\n\n[⚠ Error: {exc}]")
353
  finally:
354
- loop.call_soon_threadsafe(queue.put_nowait, None) # sentinel
355
 
356
- loop.run_in_executor(None, _run)
357
 
358
  while True:
359
  token = await queue.get()
@@ -362,6 +376,46 @@ async def _stream_tokens(llm, messages: list, params: dict, loop: asyncio.Abstra
362
  yield token
363
 
364
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  # ─────────────────────────────────────────────────────────────────────────────
366
  # Gradio Server
367
  # ─────────────────────────────────────────────────────────────────────────────
@@ -370,114 +424,116 @@ demo = Server()
370
 
371
  @demo.get("/", response_class=HTMLResponse)
372
  async def homepage():
373
- html_path = pathlib.Path(__file__).parent / "index.html"
374
- with open(html_path, encoding="utf-8") as f:
375
- return f.read()
376
 
377
 
378
  @demo.get("/api/models")
379
  async def api_models():
380
- """Return model registry (without sensitive fields)."""
381
- safe = {}
382
  for mid, cfg in MODELS.items():
383
- safe[mid] = {
384
- k: cfg[k]
385
- for k in ("id", "name", "tag", "color", "ctx", "vision", "thinking")
386
- if k in cfg
387
- }
388
- safe[mid]["status"] = _load_status.get(mid, "idle")
389
- safe[mid]["api"] = cfg.get("api", False)
390
- return JSONResponse(safe)
391
 
392
 
393
  @demo.post("/api/load")
394
  async def api_load(request: Request):
395
- """Background-load a model and return status."""
396
  data = await request.json()
397
- model_id = data.get("model_id", "")
398
- if model_id not in MODELS:
 
399
  return JSONResponse({"error": "unknown model"}, status_code=400)
400
- if MODELS[model_id].get("api"):
401
- return JSONResponse({"status": "api", "message": "No local download needed."})
402
 
403
- # Trigger load in background thread
404
- if _load_status.get(model_id) not in ("loading", "ready"):
 
405
  loop = asyncio.get_event_loop()
406
- loop.run_in_executor(None, load_model, model_id)
 
407
 
408
- return JSONResponse({"status": _load_status.get(model_id, "loading")})
409
 
410
 
411
  @demo.post("/stream/chat")
412
  async def stream_chat(request: Request):
413
- """SSE streaming inference endpoint."""
414
- data = await request.json()
415
- model_id = data.get("model_id", "v46")
416
  message = data.get("message", "")
417
  history = data.get("history", [])
418
  image_b64 = data.get("image_b64")
419
  params = data.get("params", {})
420
 
421
- cfg = MODELS.get(model_id)
422
  if not cfg:
423
  return JSONResponse({"error": "unknown model"}, status_code=400)
424
 
425
- # ── API-mode models (MiniCPM-o 4.5) ──────────────────────────────────
426
- if cfg.get("api"):
 
 
427
  async def _api_sse():
428
- # Placeholder: replace with real openbmb API endpoint when available
429
- yield f"data: {json.dumps({'token': '⚑ MiniCPM-o 4.5 (API mode) β€” '})}\n\n"
430
- yield f"data: {json.dumps({'token': 'Connect your OpenBMB API key via the settings panel.'})}\n\n"
431
  yield f"data: {json.dumps({'done': True})}\n\n"
432
- return StreamingResponse(
433
- _api_sse(),
434
- media_type="text/event-stream",
435
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
436
- )
437
-
438
- # ── llama.cpp inference ───────────────────────────────────────────────
439
- llm = load_model(model_id)
440
- if llm is None:
441
- status = _load_status.get(model_id, "unknown")
442
- err_msg = f"Model '{model_id}' not ready (status: {status}). Load it first."
443
 
 
 
 
 
444
  async def _err_sse():
445
- yield f"data: {json.dumps({'token': f'⚠ {err_msg}'})}\n\n"
446
  yield f"data: {json.dumps({'done': True})}\n\n"
447
  return StreamingResponse(_err_sse(), media_type="text/event-stream",
448
  headers={"Cache-Control": "no-cache"})
449
 
450
- messages = _build_messages(message, history, image_b64)
451
- loop = asyncio.get_event_loop()
452
- start_time = time.monotonic()
453
- token_count = [0]
 
 
 
 
 
 
 
 
 
 
 
 
454
 
455
- async def sse_generator():
456
- async for token in _stream_tokens(llm, messages, params, loop):
457
- token_count[0] += 1
458
- elapsed = time.monotonic() - start_time
459
- speed = round(token_count[0] / elapsed, 1) if elapsed > 0 else 0
460
- payload = json.dumps({"token": token, "speed": speed, "n": token_count[0]})
461
- yield f"data: {payload}\n\n"
462
- yield f"data: {json.dumps({'done': True, 'total': token_count[0]})}\n\n"
463
 
464
  return StreamingResponse(
465
- sse_generator(),
466
  media_type="text/event-stream",
467
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
 
468
  )
469
 
470
 
471
  @demo.get("/health")
472
  async def health():
473
- loaded = [mid for mid, s in _load_status.items() if s == "ready"]
474
- return JSONResponse({"status": "ok", "loaded_models": loaded})
 
 
 
 
 
 
475
 
476
 
477
  # ─────────────────────────────────────────────────────────────────────────────
478
  if __name__ == "__main__":
479
- # Pre-load the smallest model on startup (optional)
480
- # threading.Thread(target=load_model, args=("v46",), daemon=True).start()
481
  demo.launch(
482
  server_name="0.0.0.0",
483
  server_port=int(os.environ.get("PORT", 7860)),
 
1
  """
2
+ MiniCPM Forge β€” Hybrid Multi-Model Showcase
3
+ Two backends, one interface:
4
+ β€’ Vision (v46, v46t) β†’ transformers + ZeroGPU (same as the working single-model space)
5
+ β€’ Text (cpm5, cpm41) β†’ llama-cpp-python (CPU/GPU, works on free tier)
6
+ β€’ Omni (o45) β†’ API placeholder
7
 
8
+ build-small-hackathon 2026 Β· Chris4K
9
  """
10
  from __future__ import annotations
11
 
 
14
  import json
15
  import os
16
  import pathlib
17
+ import re
18
  import threading
19
  import time
20
  import uuid
21
  from io import BytesIO
22
+ from typing import Generator, Optional
23
 
24
  from PIL import Image
25
  from fastapi import Request
26
  from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
27
  from gradio import Server
 
28
 
29
  # ─────────────────────────────────────────────────────────────────────────────
30
+ # ZeroGPU (optional β€” works on HF Spaces GPU / ZeroGPU tiers)
31
  # ─────────────────────────────────────────────────────────────────────────────
32
  try:
33
  import spaces # type: ignore
34
+ HAS_SPACES_GPU = True
35
  except ImportError:
36
+ HAS_SPACES_GPU = False
37
+ class _FakeSpaces:
38
  @staticmethod
39
  def GPU(duration: int = 120):
40
+ def _wrap(fn): return fn
 
41
  return _wrap
42
+ spaces = _FakeSpaces() # type: ignore
43
 
44
  # ─────────────────────────────────────────────────────────────────────────────
45
+ # Model registry
 
 
 
 
 
 
 
 
46
  # ─────────────────────────────────────────────────────────────────────────────
47
  MODELS: dict[str, dict] = {
48
  "v46": {
 
50
  "name": "MiniCPM-V 4.6",
51
  "tag": "Vision Β· OCR",
52
  "color": "#06b6d4",
53
+ "backend": "transformers",
54
+ "hf_id": "openbmb/MiniCPM-V-4.6",
55
+ "thinking": False,
56
  "ctx": 8192,
57
  "vision": True,
 
 
58
  },
59
  "v46t": {
60
  "id": "v46t",
61
  "name": "MiniCPM-V 4.6-T",
62
  "tag": "Vision Β· Thinking",
63
  "color": "#a855f7",
64
+ "backend": "transformers",
65
+ "hf_id": "openbmb/MiniCPM-V-4.6-Thinking",
66
+ "thinking": True,
67
  "ctx": 8192,
68
  "vision": True,
 
 
69
  },
70
  "cpm5": {
71
  "id": "cpm5",
72
  "name": "MiniCPM5-1B",
73
  "tag": "⚑ Ultra-light",
74
  "color": "#22c55e",
75
+ "backend": "llama",
76
  "repo": "openbmb/MiniCPM5-1B-GGUF",
77
+ "file": "MiniCPM5-1B-Q4_K_M.gguf", # βœ“ confirmed working
78
  "ctx": 4096,
79
  "vision": False,
80
  "thinking": False,
 
81
  },
82
  "cpm41": {
83
  "id": "cpm41",
84
  "name": "MiniCPM4.1-8B",
85
  "tag": "🧠 Reasoning",
86
  "color": "#f97316",
87
+ "backend": "llama",
88
+ "repo": "openbmb/MiniCPM4.1-8B-GGUF",
89
+ "file": "MiniCPM4.1-8B-Q4_K_M.gguf",
90
  "ctx": 16384,
91
  "vision": False,
92
  "thinking": True,
 
93
  },
94
  "o45": {
95
  "id": "o45",
96
  "name": "MiniCPM-o 4.5",
97
  "tag": "🌐 Omni",
98
  "color": "#ec4899",
99
+ "backend": "api",
100
  "ctx": 8192,
101
  "vision": True,
102
  "thinking": False,
 
 
103
  },
104
  }
105
 
106
  # ─────────────────────────────────────────────────────────────────────────────
107
+ # Shared state
108
  # ─────────────────────────────────────────────────────────────────────────────
109
  CACHE_DIR = pathlib.Path(os.environ.get("HF_HOME", "/tmp/hf_cache")) / "forge"
110
+ _load_status: dict[str, str] = {}
111
  _load_lock = threading.Lock()
 
112
 
113
+ # llama-cpp loaded models
114
+ _llama_models: dict[str, object] = {}
115
 
116
+ # transformers loaded models (processor + model per id)
117
+ _tr_processors: dict[str, object] = {}
118
+ _tr_models: dict[str, object] = {}
119
 
120
+ # ─────────────────────────────────────────────────────────────────────────────
121
+ # Text normalisation (from official openbmb demo)
122
+ # ─────────────────────────────────────────────────────────────────────────────
123
+ _NORM_PATTERN = re.compile(
124
+ r'(```[\s\S]*?```|`[^`]+`|\$\$[\s\S]*?\$\$|\$[^$]+\$'
125
+ r'|\\\([\s\S]*?\\\)|\\\[[\s\S]*?\\\])'
126
+ r'|(?<!\\)(?:\\r\\n|\\[nr])'
127
+ )
128
+
129
+ def normalize_response_text(text: str) -> str:
130
+ if not isinstance(text, str) or "\\" not in text:
131
+ return text
132
+ return _NORM_PATTERN.sub(lambda m: m.group(1) or '\n', text)
133
+
134
+ # ─────────────────────────────────────────────────────────────────────────────
135
+ # Backend A: transformers (vision models, runs via ZeroGPU when available)
136
+ # ─────────────────────────────────────────────────────────────────────────────
137
+
138
+ def _load_transformers(model_id: str) -> None:
139
+ """Load processor + model into _tr_processors/_tr_models. Thread-safe."""
140
+ if model_id in _tr_models:
141
+ return
142
+ with _load_lock:
143
+ if model_id in _tr_models:
144
+ return
145
+ _load_status[model_id] = "loading"
146
+ cfg = MODELS[model_id]
147
+ hf_id = cfg["hf_id"]
148
+ print(f"[forge] Loading transformers model: {hf_id}")
149
+ try:
150
+ import torch
151
+ from transformers import AutoProcessor, AutoModelForImageTextToText # type: ignore
152
+
153
+ processor = AutoProcessor.from_pretrained(hf_id, trust_remote_code=True)
154
+
155
+ if torch.cuda.is_available():
156
+ model = AutoModelForImageTextToText.from_pretrained(
157
+ hf_id,
158
+ torch_dtype=torch.bfloat16,
159
+ attn_implementation="sdpa",
160
+ trust_remote_code=True,
161
+ device_map="cuda",
162
+ ).eval()
163
+ else:
164
+ # CPU fallback β€” slow but functional for demos
165
+ model = AutoModelForImageTextToText.from_pretrained(
166
+ hf_id,
167
+ torch_dtype=torch.float32,
168
+ trust_remote_code=True,
169
+ device_map="cpu",
170
+ ).eval()
171
+
172
+ _tr_processors[model_id] = processor
173
+ _tr_models[model_id] = model
174
+ _load_status[model_id] = "ready"
175
+ print(f"[forge] βœ“ transformers {model_id} ready")
176
+ except Exception as exc:
177
+ _load_status[model_id] = f"error:{exc}"
178
+ print(f"[forge] βœ— transformers {model_id} failed: {exc}")
179
+ raise
180
+
181
+
182
+ @spaces.GPU(duration=120)
183
+ def _run_transformers(
184
+ model_id: str,
185
+ messages: list,
186
+ params: dict,
187
+ ) -> Generator[str, None, None]:
188
  """
189
+ Sync generator β€” yields *delta* text chunks.
190
+ Decorated with @spaces.GPU so it runs on ZeroGPU when available;
191
+ falls back to CPU silently when spaces is not installed.
192
+ """
193
+ import torch
194
+ from transformers import TextIteratorStreamer # type: ignore
195
+
196
+ processor = _tr_processors[model_id]
197
+ model = _tr_models[model_id]
198
+ thinking = params.get("thinking_mode", False)
199
+
200
+ is_video = any(
201
+ it.get("type") == "video"
202
+ for msg in messages
203
+ for it in (msg.get("content") or [])
204
  )
205
 
206
+ with torch.no_grad():
207
+ inputs = processor.apply_chat_template(
208
+ messages,
209
+ add_generation_prompt=True,
210
+ tokenize=True,
211
+ return_dict=True,
212
+ return_tensors="pt",
213
+ enable_thinking=thinking,
214
+ processor_kwargs={
215
+ "downsample_mode": "16x",
216
+ "max_slice_nums": 1 if is_video else 9,
217
+ "use_image_id": not is_video,
218
+ },
219
+ ).to(model.device)
220
+
221
+ if model.device.type == "cuda":
222
+ import torch as _torch
223
+ for k, v in inputs.items():
224
+ if isinstance(v, _torch.Tensor) and _torch.is_floating_point(v):
225
+ inputs[k] = v.to(dtype=_torch.bfloat16)
226
+
227
+ streamer = TextIteratorStreamer(
228
+ processor.tokenizer,
229
+ skip_prompt=True,
230
+ skip_special_tokens=True,
231
+ timeout=60.0,
232
+ )
233
 
234
+ gen_kw = {
235
+ **inputs,
236
+ "max_new_tokens": params.get("max_tokens", 1024),
237
+ "do_sample": True,
238
+ "temperature": params.get("temperature", 0.7),
239
+ "top_p": params.get("top_p", 0.8),
240
+ "top_k": int(params.get("top_k", 100)),
241
+ "streamer": streamer,
242
+ "downsample_mode": "16x",
243
+ }
244
 
245
+ t = threading.Thread(target=model.generate, kwargs=gen_kw, daemon=True)
246
+ t.start()
 
 
 
247
 
248
+ for chunk in streamer:
249
+ yield normalize_response_text(chunk)
250
 
251
+ t.join(timeout=10)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
 
 
 
 
 
 
 
253
 
254
+ async def _stream_transformers(
255
+ model_id: str,
256
+ messages: list,
257
+ params: dict,
258
+ loop: asyncio.AbstractEventLoop,
259
+ ):
260
+ """Async generator: bridges sync _run_transformers β†’ async SSE."""
261
+ queue: asyncio.Queue = asyncio.Queue(maxsize=256)
262
+
263
+ def _worker():
264
+ try:
265
+ for chunk in _run_transformers(model_id, messages, params):
266
+ loop.call_soon_threadsafe(queue.put_nowait, chunk)
267
+ except Exception as exc:
268
+ loop.call_soon_threadsafe(queue.put_nowait, f"\n\n[⚠ {exc}]")
269
+ finally:
270
+ loop.call_soon_threadsafe(queue.put_nowait, None)
271
+
272
+ loop.run_in_executor(None, _worker)
273
+
274
+ while True:
275
+ token = await queue.get()
276
+ if token is None:
277
+ break
278
+ yield token
279
+
280
 
281
+ # ─────────────────────────────────────────────────────────────────────────────
282
+ # Backend B: llama-cpp (text models)
283
+ # ─────────────────────────────────────────────────────────────────────────────
284
 
285
+ def _hub_download_robust(repo_id: str, filename: str, local_dir: str) -> str:
286
+ """hf_hub_download with glob fallback for filename drift."""
287
  import fnmatch
288
+ from huggingface_hub import hf_hub_download, list_repo_files # type: ignore
289
 
290
+ pathlib.Path(local_dir).mkdir(parents=True, exist_ok=True)
291
  try:
292
+ return hf_hub_download(repo_id=repo_id, filename=filename, local_dir=local_dir)
 
293
  except Exception:
294
  pass
295
+ # Glob fallback
296
+ quant = re.search(r'(Q\d_K_[MS]|Q\d_\d|F16|BF16)', filename)
297
+ pat = f"*{quant.group(1)}*.gguf" if quant else f"*{pathlib.Path(filename).stem}*"
298
+ candidates = [f for f in list_repo_files(repo_id)
299
+ if fnmatch.fnmatch(f, pat) and f.endswith(".gguf")]
300
  if not candidates:
301
  raise FileNotFoundError(
302
+ f"No file matching {pat!r} in {repo_id}. "
303
+ f"GGUFs: {[f for f in list_repo_files(repo_id) if f.endswith('.gguf')]}"
304
  )
305
+ best = next((f for f in candidates if "Q4_K_M" in f), candidates[0])
306
+ print(f" β†’ glob fallback: {best!r}")
307
  return hf_hub_download(repo_id=repo_id, filename=best, local_dir=local_dir)
308
 
309
 
310
+ def _load_llama(model_id: str) -> None:
311
+ """Load a text-only GGUF model via llama-cpp-python."""
312
+ if model_id in _llama_models:
313
+ return
 
 
 
 
 
314
  with _load_lock:
315
+ if model_id in _llama_models:
316
+ return
 
317
  _load_status[model_id] = "loading"
318
+ cfg = MODELS[model_id]
319
  local_dir = str(CACHE_DIR / model_id)
320
+ print(f"[forge] Downloading LM: {cfg['repo']} / {cfg['file']}")
321
  try:
322
  from llama_cpp import Llama # type: ignore
323
 
324
+ model_path = _hub_download_robust(cfg["repo"], cfg["file"], local_dir)
325
+ n_gpu = int(os.environ.get("N_GPU_LAYERS", "-1"))
326
+ llm = Llama(
327
+ model_path=model_path,
328
+ n_ctx=cfg["ctx"],
329
+ n_gpu_layers=n_gpu,
330
+ verbose=False,
331
  )
332
+ _llama_models[model_id] = llm
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  _load_status[model_id] = "ready"
334
+ print(f"[forge] βœ“ llama {model_id} ready")
 
 
335
  except Exception as exc:
336
  _load_status[model_id] = f"error:{exc}"
337
+ print(f"[forge] βœ— llama {model_id} failed: {exc}")
338
+ raise
 
339
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
 
341
+ async def _stream_llama(
342
+ llm,
343
+ messages: list,
344
+ params: dict,
345
+ loop: asyncio.AbstractEventLoop,
346
+ ):
347
+ """Async generator: bridges sync llama-cpp stream β†’ async SSE."""
348
+ queue: asyncio.Queue = asyncio.Queue(maxsize=256)
349
 
350
+ def _worker():
 
 
 
 
 
351
  try:
352
  output = llm.create_chat_completion(
353
  messages=messages,
 
355
  max_tokens=params.get("max_tokens", 1024),
356
  temperature=params.get("temperature", 0.7),
357
  top_p=params.get("top_p", 0.8),
358
+ top_k=int(params.get("top_k", 100)),
359
  repeat_penalty=params.get("repeat_penalty", 1.05),
360
  )
361
  for chunk in output:
 
363
  if delta:
364
  loop.call_soon_threadsafe(queue.put_nowait, delta)
365
  except Exception as exc:
366
+ loop.call_soon_threadsafe(queue.put_nowait, f"\n\n[⚠ {exc}]")
367
  finally:
368
+ loop.call_soon_threadsafe(queue.put_nowait, None)
369
 
370
+ loop.run_in_executor(None, _worker)
371
 
372
  while True:
373
  token = await queue.get()
 
376
  yield token
377
 
378
 
379
+ # ─────────────────────────────────────────────────────────────────────────────
380
+ # Shared message builder
381
+ # ─────────────────────────────────────────────────────────────────────────────
382
+
383
+ def _build_messages(
384
+ message: str,
385
+ history: list,
386
+ image_b64: Optional[str],
387
+ backend: str,
388
+ ) -> list[dict]:
389
+ msgs: list[dict] = []
390
+
391
+ for turn in history or []:
392
+ if turn.get("user"):
393
+ msgs.append({"role": "user",
394
+ "content": [{"type": "text", "text": turn["user"]}]})
395
+ if turn.get("assistant"):
396
+ msgs.append({"role": "assistant",
397
+ "content": [{"type": "text", "text": turn["assistant"]}]})
398
+
399
+ content: list[dict] = []
400
+
401
+ if image_b64:
402
+ if backend == "transformers":
403
+ # transformers expects a PIL Image object
404
+ img_bytes = base64.b64decode(image_b64)
405
+ img = Image.open(BytesIO(img_bytes)).convert("RGB")
406
+ content.append({"type": "image", "image": img})
407
+ else:
408
+ # llama-cpp expects a data: URI
409
+ raw = base64.b64decode(image_b64[:32])
410
+ mime = "image/png" if raw[:8] == b"\x89PNG\r\n\x1a\n" else "image/jpeg"
411
+ content.append({"type": "image_url",
412
+ "image_url": {"url": f"data:{mime};base64,{image_b64}"}})
413
+
414
+ content.append({"type": "text", "text": message or "Describe this image."})
415
+ msgs.append({"role": "user", "content": content})
416
+ return msgs
417
+
418
+
419
  # ─────────────────────────────────────────────────────────────────────────────
420
  # Gradio Server
421
  # ─────────────────────────────────────────────────────────────────────────────
 
424
 
425
  @demo.get("/", response_class=HTMLResponse)
426
  async def homepage():
427
+ html = pathlib.Path(__file__).parent / "index.html"
428
+ return html.read_text(encoding="utf-8")
 
429
 
430
 
431
  @demo.get("/api/models")
432
  async def api_models():
433
+ out = {}
 
434
  for mid, cfg in MODELS.items():
435
+ out[mid] = {k: cfg[k] for k in ("id", "name", "tag", "color", "ctx", "vision", "thinking")
436
+ if k in cfg}
437
+ out[mid]["status"] = _load_status.get(mid, "idle")
438
+ out[mid]["api"] = cfg.get("backend") == "api"
439
+ out[mid]["backend"] = cfg.get("backend", "llama")
440
+ return JSONResponse(out)
 
 
441
 
442
 
443
  @demo.post("/api/load")
444
  async def api_load(request: Request):
 
445
  data = await request.json()
446
+ mid = data.get("model_id", "")
447
+ cfg = MODELS.get(mid)
448
+ if not cfg:
449
  return JSONResponse({"error": "unknown model"}, status_code=400)
450
+ if cfg["backend"] == "api":
451
+ return JSONResponse({"status": "api"})
452
 
453
+ # Kick off load in a background thread if not already running
454
+ current = _load_status.get(mid, "idle")
455
+ if current not in ("loading", "ready"):
456
  loop = asyncio.get_event_loop()
457
+ loader = _load_transformers if cfg["backend"] == "transformers" else _load_llama
458
+ loop.run_in_executor(None, loader, mid)
459
 
460
+ return JSONResponse({"status": _load_status.get(mid, "loading")})
461
 
462
 
463
  @demo.post("/stream/chat")
464
  async def stream_chat(request: Request):
465
+ data = await request.json()
466
+ mid = data.get("model_id", "cpm5")
 
467
  message = data.get("message", "")
468
  history = data.get("history", [])
469
  image_b64 = data.get("image_b64")
470
  params = data.get("params", {})
471
 
472
+ cfg = MODELS.get(mid)
473
  if not cfg:
474
  return JSONResponse({"error": "unknown model"}, status_code=400)
475
 
476
+ backend = cfg.get("backend", "llama")
477
+
478
+ # ── API mode ──────────────────────────────────────────────────────────────
479
+ if backend == "api":
480
  async def _api_sse():
481
+ yield f"data: {json.dumps({'token': '🌐 MiniCPM-o 4.5 API mode β€” set OPENBMB_API_KEY in Space secrets.'})}\n\n"
 
 
482
  yield f"data: {json.dumps({'done': True})}\n\n"
483
+ return StreamingResponse(_api_sse(), media_type="text/event-stream",
484
+ headers={"Cache-Control": "no-cache"})
 
 
 
 
 
 
 
 
 
485
 
486
+ # ── Check model is loaded ─────────────────────────────────────────────────
487
+ store = _tr_models if backend == "transformers" else _llama_models
488
+ if mid not in store:
489
+ msg = f"Model '{mid}' not loaded (status: {_load_status.get(mid, 'idle')}). Click ⬇ LOAD first."
490
  async def _err_sse():
491
+ yield f"data: {json.dumps({'token': f'⚠ {msg}'})}\n\n"
492
  yield f"data: {json.dumps({'done': True})}\n\n"
493
  return StreamingResponse(_err_sse(), media_type="text/event-stream",
494
  headers={"Cache-Control": "no-cache"})
495
 
496
+ messages = _build_messages(message, history, image_b64, backend)
497
+ loop = asyncio.get_event_loop()
498
+ t0 = time.monotonic()
499
+ n_tok = [0]
500
+
501
+ async def sse_gen():
502
+ if backend == "transformers":
503
+ gen = _stream_transformers(mid, messages, params, loop)
504
+ else:
505
+ gen = _stream_llama(_llama_models[mid], messages, params, loop)
506
+
507
+ async for token in gen:
508
+ n_tok[0] += 1
509
+ elapsed = time.monotonic() - t0
510
+ speed = round(n_tok[0] / elapsed, 1) if elapsed > 0 else 0
511
+ yield f"data: {json.dumps({'token': token, 'speed': speed, 'n': n_tok[0]})}\n\n"
512
 
513
+ yield f"data: {json.dumps({'done': True, 'total': n_tok[0]})}\n\n"
 
 
 
 
 
 
 
514
 
515
  return StreamingResponse(
516
+ sse_gen(),
517
  media_type="text/event-stream",
518
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no",
519
+ "Connection": "keep-alive"},
520
  )
521
 
522
 
523
  @demo.get("/health")
524
  async def health():
525
+ return JSONResponse({
526
+ "status": "ok",
527
+ "backends": {
528
+ "transformers": list(_tr_models.keys()),
529
+ "llama": list(_llama_models.keys()),
530
+ },
531
+ "spaces_gpu": HAS_SPACES_GPU,
532
+ })
533
 
534
 
535
  # ─────────────────────────────────────────────────────────────────────────────
536
  if __name__ == "__main__":
 
 
537
  demo.launch(
538
  server_name="0.0.0.0",
539
  server_port=int(os.environ.get("PORT", 7860)),