nz-nz commited on
Commit
6736c51
·
verified ·
1 Parent(s): 847a58e

Sync from GitHub via hub-sync

Browse files
Files changed (5) hide show
  1. .gitignore +0 -37
  2. llm.py +136 -8
  3. requirements-model.txt +39 -13
  4. requirements.txt +12 -9
  5. test_model_config.py +15 -4
.gitignore DELETED
@@ -1,37 +0,0 @@
1
- # Secrets / env
2
- .env
3
- .env.*
4
- !.env.example
5
-
6
- # Temp
7
- .tmp/
8
-
9
- # Python
10
- __pycache__/
11
- *.py[cod]
12
- *$py.class
13
- *.so
14
- .Python
15
- build/
16
- dist/
17
- *.egg-info/
18
- .eggs/
19
-
20
- # Virtual environments
21
- .venv/
22
- venv/
23
- env/
24
- ENV/
25
-
26
- # Test / coverage
27
- .pytest_cache/
28
- .mypy_cache/
29
- .ruff_cache/
30
- .coverage
31
- htmlcov/
32
-
33
- # OS / editor
34
- .DS_Store
35
- *.swp
36
- .idea/
37
- .vscode/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
llm.py CHANGED
@@ -32,16 +32,30 @@ STUB = os.getenv("RECALL_STUB", "1") == "1"
32
 
33
  # Known models, keyed by short alias so swapping is a single env-var flip.
34
  MODELS = {
35
- "8b": "openbmb/MiniCPM4.1-8B", # default / primary
36
- "1b": "openbmb/MiniCPM5-1B", # fast fallback if the Space is slow / OOM
37
- "4b": "openbmb/MiniCPM3-4B", # mid fallback (Tiny Titan badge)
 
38
  }
39
- _requested = os.getenv("RECALL_MODEL", "8b")
40
- # Accept an alias ("1b") or a full HF id ("org/model") passed through verbatim.
 
 
 
41
  MODEL_ID = MODELS.get(_requested, _requested)
42
 
 
 
 
 
 
 
 
 
 
43
  _model = None
44
  _tokenizer = None
 
45
 
46
 
47
  def active_model() -> str:
@@ -93,6 +107,103 @@ def _load():
93
  )
94
 
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  def _render_prompt(messages: list[dict]) -> str:
97
  """Build the prompt string. MiniCPM4.1/MiniCPM5 are hybrid reasoning models;
98
  we pass enable_thinking=False so they answer directly instead of spending the
@@ -118,12 +229,19 @@ def chat(messages: list[dict], max_tokens: int = 512) -> str:
118
  messages: [{"role": "system"|"user"|"assistant", "content": str}, ...]
119
  Returns the assistant's text.
120
 
121
- On a HF ZeroGPU Space, wrap the *callers'* entrypoints (or this function)
122
- with @spaces.GPU. Keep max_tokens tightlatency is the demo killer.
 
 
 
 
123
  """
124
  if STUB:
125
  return _stub_reply(messages)
126
 
 
 
 
127
  _load()
128
  text = _render_prompt(messages)
129
  inputs = _tokenizer(text, return_tensors="pt").to(_model.device)
@@ -232,9 +350,19 @@ def chat_json(messages: list[dict], max_tokens: int = 256, retries: int = 1):
232
 
233
  # ---- Stub replies so the app runs with no model ----------------------------
234
 
 
 
 
 
 
 
 
 
 
 
235
  def _stub_reply(messages: list[dict]) -> str:
236
  """Cheap deterministic-ish replies keyed off the caller's intent tag."""
237
- content = " ".join(m.get("content", "") for m in messages).lower()
238
  if "generate" in content and "question" in content:
239
  return json.dumps([
240
  {"question": "[stub] What is the main idea of the source text?",
 
32
 
33
  # Known models, keyed by short alias so swapping is a single env-var flip.
34
  MODELS = {
35
+ "v46": "openbmb/MiniCPM-V-4.6", # default / primary — multimodal (text + image)
36
+ "8b": "openbmb/MiniCPM4.1-8B", # legacy text-only (needs transformers<5.0)
37
+ "1b": "openbmb/MiniCPM5-1B", # legacy fast fallback
38
+ "4b": "openbmb/MiniCPM3-4B", # legacy mid fallback (Tiny Titan badge)
39
  }
40
+ # Default is the multimodal MiniCPM-V 4.6 so the same model grades text AND reads
41
+ # image-only / scanned PDFs. The legacy text aliases need transformers<5.0 and no
42
+ # longer load against the pinned transformers 5.x — keep them only for reference.
43
+ _requested = os.getenv("RECALL_MODEL", "v46")
44
+ # Accept an alias ("v46") or a full HF id ("org/model") passed through verbatim.
45
  MODEL_ID = MODELS.get(_requested, _requested)
46
 
47
+
48
+ def _is_vision_model(model_id: str) -> bool:
49
+ """MiniCPM-V (vision) ids load via a different class + processor than the
50
+ text-only MiniCPM models. Detect by the '-V' family marker."""
51
+ return "minicpm-v" in model_id.lower()
52
+
53
+
54
+ VISION = _is_vision_model(MODEL_ID)
55
+
56
  _model = None
57
  _tokenizer = None
58
+ _processor = None # MiniCPM-V uses an AutoProcessor (image+text) instead of a tokenizer
59
 
60
 
61
  def active_model() -> str:
 
107
  )
108
 
109
 
110
+ # ---- MiniCPM-V (multimodal) path -------------------------------------------
111
+ # NEEDS GPU VERIFICATION: the calls below mirror the official MiniCPM-V-4.6 demo
112
+ # Space (openbmb/MiniCPM-V-4.6-Demo) but can't be exercised without a GPU + the
113
+ # ~9B model. The stub and legacy text paths are unchanged and remain testable.
114
+
115
+
116
+ def _maybe_gpu(fn):
117
+ """Wrap with HF ZeroGPU's @spaces.GPU when available; otherwise a no-op.
118
+ `spaces` ships only in the real-model deps and is effect-free off a ZeroGPU
119
+ Space, so this is safe in stub/local environments where it isn't installed.
120
+ Registering a @spaces.GPU function is ALSO what keeps a ZeroGPU Space healthy
121
+ (a ZeroGPU Space with none flips to RUNTIME_ERROR — see server.py)."""
122
+ try:
123
+ import spaces
124
+ except Exception: # noqa: BLE001 — not installed (stub/local): run un-wrapped
125
+ return fn
126
+ return spaces.GPU(duration=120)(fn)
127
+
128
+
129
+ def _load_vision() -> None:
130
+ """Lazy-load the MiniCPM-V model + processor once. Only called when STUB is
131
+ off and the active model is a vision model."""
132
+ global _model, _processor
133
+ if _model is not None:
134
+ return
135
+ import torch
136
+ from transformers import AutoProcessor, MiniCPMV4_6ForConditionalGeneration
137
+
138
+ dtype = getattr(torch, _resolve_dtype_name())
139
+ device_map = _resolve_device_map()
140
+ print(f"[recall] loading vision model: {MODEL_ID} (dtype={_resolve_dtype_name()}, "
141
+ f"device_map={device_map})")
142
+ _processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True)
143
+ _model = MiniCPMV4_6ForConditionalGeneration.from_pretrained(
144
+ MODEL_ID,
145
+ torch_dtype=dtype,
146
+ attn_implementation="sdpa",
147
+ trust_remote_code=True,
148
+ device_map=device_map,
149
+ ).eval()
150
+
151
+
152
+ def _to_vision_content(content):
153
+ """Normalize a message's `content` to MiniCPM-V parts. Accepts a plain string
154
+ (text-only) or a list mixing strings and PIL.Image objects (image+text)."""
155
+ if isinstance(content, str):
156
+ return [{"type": "text", "text": content}]
157
+ parts = []
158
+ for item in content:
159
+ if isinstance(item, str):
160
+ parts.append({"type": "text", "text": item})
161
+ else: # a PIL.Image (or anything image-like the processor accepts)
162
+ parts.append({"type": "image", "image": item})
163
+ return parts
164
+
165
+
166
+ @_maybe_gpu
167
+ def _chat_vision(messages: list[dict], max_tokens: int) -> str:
168
+ """MiniCPM-V 4.6 inference, mirroring the official demo's processor+generate
169
+ call (non-streaming). enable_thinking=False keeps the tight token budget for
170
+ the JSON answer instead of a <think> preamble."""
171
+ _load_vision()
172
+ import torch
173
+
174
+ msgs = [{"role": m["role"], "content": _to_vision_content(m["content"])}
175
+ for m in messages]
176
+ inputs = _processor.apply_chat_template(
177
+ msgs,
178
+ add_generation_prompt=True,
179
+ tokenize=True,
180
+ return_dict=True,
181
+ return_tensors="pt",
182
+ enable_thinking=False,
183
+ processor_kwargs={
184
+ "downsample_mode": "16x",
185
+ "max_slice_nums": 9,
186
+ "use_image_id": True,
187
+ },
188
+ ).to(_model.device)
189
+ # MiniCPM-V wants floating inputs (e.g. pixel_values) in the model dtype.
190
+ for k, v in inputs.items():
191
+ if isinstance(v, torch.Tensor) and torch.is_floating_point(v):
192
+ inputs[k] = v.to(dtype=getattr(torch, _resolve_dtype_name()))
193
+
194
+ with torch.no_grad():
195
+ out = _model.generate(
196
+ **inputs,
197
+ max_new_tokens=max_tokens,
198
+ do_sample=True,
199
+ temperature=0.7,
200
+ top_p=0.9,
201
+ downsample_mode="16x",
202
+ )
203
+ gen = out[0][inputs["input_ids"].shape[1]:]
204
+ return _processor.tokenizer.decode(gen, skip_special_tokens=True).strip()
205
+
206
+
207
  def _render_prompt(messages: list[dict]) -> str:
208
  """Build the prompt string. MiniCPM4.1/MiniCPM5 are hybrid reasoning models;
209
  we pass enable_thinking=False so they answer directly instead of spending the
 
229
  messages: [{"role": "system"|"user"|"assistant", "content": str}, ...]
230
  Returns the assistant's text.
231
 
232
+ `content` is normally a str. For the multimodal model it may also be a list
233
+ mixing strings and PIL.Image objects (image+text) e.g. for image-only PDFs.
234
+
235
+ GPU work is wrapped with @spaces.GPU inside the vision path; that decorator is
236
+ also what keeps a ZeroGPU Space healthy. Keep max_tokens tight — latency is
237
+ the demo killer.
238
  """
239
  if STUB:
240
  return _stub_reply(messages)
241
 
242
+ if VISION:
243
+ return _chat_vision(messages, max_tokens)
244
+
245
  _load()
246
  text = _render_prompt(messages)
247
  inputs = _tokenizer(text, return_tensors="pt").to(_model.device)
 
350
 
351
  # ---- Stub replies so the app runs with no model ----------------------------
352
 
353
+ def _msg_text(content) -> str:
354
+ """Text of a message's content, ignoring any images (content may be a str or
355
+ a list mixing strings and PIL.Image objects)."""
356
+ if isinstance(content, str):
357
+ return content
358
+ if isinstance(content, list):
359
+ return " ".join(p for p in content if isinstance(p, str))
360
+ return ""
361
+
362
+
363
  def _stub_reply(messages: list[dict]) -> str:
364
  """Cheap deterministic-ish replies keyed off the caller's intent tag."""
365
+ content = " ".join(_msg_text(m.get("content", "")) for m in messages).lower()
366
  if "generate" in content and "question" in content:
367
  return json.dumps([
368
  {"question": "[stub] What is the main idea of the source text?",
requirements-model.txt CHANGED
@@ -2,21 +2,47 @@
2
  # Superset of the light demo set. Install with:
3
  # pip install -r requirements-model.txt
4
  #
5
- # NOTE for HF deploy: a Gradio Space installs `requirements.txt`, not this file.
6
- # To serve the real model on the Space, point the Space at these deps (e.g. fold
7
- # them back into requirements.txt for the model deploy) see NAH-7.
 
 
 
 
 
 
 
8
  -r requirements.txt
9
- # transformers is pinned to the last 4.x line: MiniCPM4.1-8B's trust_remote_code
10
- # imports symbols removed in transformers 5.0 (is_torch_fx_available,
11
- # is_torch_greater_or_equal_than_1_13) while also needing the newer cache API
12
- # (CacheLayerMixin) only 4.55–4.57 satisfy both. transformers<5.0 in turn
13
- # requires huggingface-hub<1.0, which requirements.txt's gradio==6.10.0 allows
14
- # (hub <2.0,>=0.33.5), whereas gradio 6.18 needs hub>=1.2. gradio 6.10.0 is also
15
- # the version that runs the custom gradio.Server cleanly on a Space. Resolvable
16
- # together: gradio 6.10.0 + hub 0.36 + transformers 4.57 + torch 2.12.
17
- transformers>=4.55,<5.0
 
 
 
 
18
  torch
 
19
  accelerate
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  # HF ZeroGPU GPU decorator (effect-free off-Space). Pulls in gradio, so it must
21
- # resolve against the 6.10.0 pin above — not the latest gradio.
22
  spaces
 
2
  # Superset of the light demo set. Install with:
3
  # pip install -r requirements-model.txt
4
  #
5
+ # Target: openbmb/MiniCPM-V-4.6 (multimodal, ~9B) to read image-only / scanned
6
+ # PDFs. Aligned with the official Space (openbmb/MiniCPM-V-4.6-Demo), which runs
7
+ # the same custom-frontend gradio.Server + ZeroGPU pattern as this app.
8
+ #
9
+ # DEPLOY (real model on the Space):
10
+ # * Fold these into requirements.txt for the model deploy (a Gradio Space
11
+ # installs requirements.txt, not this file) — see NAH-7.
12
+ # * Switch hardware back to ZeroGPU AND register the inference under @spaces.GPU,
13
+ # and name the gradio.Server `demo` — a ZeroGPU Space with no @spaces.GPU fn
14
+ # flips to RUNTIME_ERROR (that's why the stub demo runs on CPU; see server.py).
15
  -r requirements.txt
16
+
17
+ # --- transformers / model ---
18
+ # MiniCPM-V 4.6 is a NATIVE transformers architecture
19
+ # (`from transformers import MiniCPMV4_6ForConditionalGeneration`), so it needs a
20
+ # recent transformers (5.x) NOT the old trust_remote_code path. The official
21
+ # demo declares `transformers>=4.44.0` and relies on latest providing the class.
22
+ #
23
+ # NOTE: this DROPS the previous `transformers<5.0` cap. That cap existed only for
24
+ # the old MiniCPM4.1-8B *text* model (its trust_remote_code broke on 5.x, forcing
25
+ # huggingface-hub<1.0). 4.6 uses new transformers (hub>=1.0), and gradio==6.10.0
26
+ # allows hub<2.0, so gradio 6.10.0 + transformers 5.x + hub 1.x resolve together.
27
+ # Moving to 4.6 therefore RETIRES the old MiniCPM4.1-8B / hub<1.0 constraint.
28
+ transformers>=4.44.0
29
  torch
30
+ torchvision # vision tower; must match the installed torch
31
  accelerate
32
+ sentencepiece # tokenizer backend the MiniCPM-V processor uses
33
+
34
+ # --- image + PDF handling ---
35
+ pillow # images are passed to the model as PIL.Image objects
36
+ # Render image-only / scanned PDF pages -> PIL images to feed the vision model.
37
+ # PyMuPDF is a pure wheel (no poppler/ffmpeg system dep) — works on a Space as-is.
38
+ # (Neither MiniCPM demo does PDFs; this is our addition for image-based PDFs.)
39
+ PyMuPDF
40
+
41
+ # --- video (OPTIONAL) ---
42
+ # Only needed if you later accept video input. Requires the `ffmpeg` system package
43
+ # (add a packages.txt with `ffmpeg`). Not needed for image-based PDFs, so left out:
44
+ # av
45
+
46
  # HF ZeroGPU GPU decorator (effect-free off-Space). Pulls in gradio, so it must
47
+ # resolve against the gradio pin in requirements.txt — not the latest gradio.
48
  spaces
requirements.txt CHANGED
@@ -7,15 +7,18 @@
7
  # the Space build stays fast — nothing heavy is imported at module load in stub mode
8
  # (llm.py imports torch/transformers only inside _load(); pypdf is imported lazily).
9
  #
10
- # gradio is pinned to 6.10.0 on purpose, for TWO reasons:
11
- # 1. The custom-frontend server (server.py) uses `gradio.Server`. On newer gradio
12
- # (6.17.x) a custom gradio.Server breaks under the Space's hot-reloader the
13
- # app starts but the process exits RUNTIME_ERROR. 6.10.0 is the version
14
- # gradio's own ZeroGPU `Server` reference example ships and runs cleanly.
15
- # 2. Real-model compatibility: 6.10.0 allows huggingface-hub<1.0
16
- # (`<2.0,>=0.33.5`), which the real model's transformers<5.0 requires
17
- # (MiniCPM trust_remote_code breaks on transformers 5.x). gradio 6.18 would
18
- # force hub>=1.2 and break that.
 
 
 
19
  # A gradio-SDK Space force-installs sdk_version's gradio for the WHOLE Space, so
20
  # stub + real-model share one gradio. Keep this in lockstep with the README
21
  # `sdk_version` and requirements-model.txt.
 
7
  # the Space build stays fast — nothing heavy is imported at module load in stub mode
8
  # (llm.py imports torch/transformers only inside _load(); pypdf is imported lazily).
9
  #
10
+ # gradio is pinned to 6.10.0 because the custom-frontend server (server.py) uses
11
+ # `gradio.Server`. On newer gradio (6.17.x) a custom gradio.Server breaks under the
12
+ # Space runtime the app starts but the Space flips to RUNTIME_ERROR. 6.10.0 is a
13
+ # version gradio's `Server` examples ship and runs cleanly here. (The official
14
+ # MiniCPM-V-4.6 demo uses 6.14.0 with the same pattern, so 6.10–6.14 is the known-
15
+ # good band; 6.10.0 is what we verified.)
16
+ #
17
+ # It also resolves with the real model: 6.10.0 allows huggingface-hub <2.0,>=0.33.5,
18
+ # i.e. hub 1.x, which MiniCPM-V 4.6's modern transformers (5.x) wants. (The old
19
+ # huggingface-hub<1.0 constraint was specific to the retired MiniCPM4.1-8B text
20
+ # model and no longer applies — see requirements-model.txt.)
21
+ #
22
  # A gradio-SDK Space force-installs sdk_version's gradio for the WHOLE Space, so
23
  # stub + real-model share one gradio. Keep this in lockstep with the README
24
  # `sdk_version` and requirements-model.txt.
test_model_config.py CHANGED
@@ -19,10 +19,11 @@ def _reload_with(model_env):
19
  return importlib.reload(llm)
20
 
21
 
22
- def test_default_is_8b():
23
  llm = _reload_with(None)
24
- assert llm.MODEL_ID == "openbmb/MiniCPM4.1-8B"
25
- print("ok default -> MiniCPM4.1-8B")
 
26
 
27
 
28
  def test_1b_alias():
@@ -40,9 +41,18 @@ def test_4b_alias():
40
  def test_full_id_passthrough():
41
  llm = _reload_with("some-org/Custom-Model-7B")
42
  assert llm.MODEL_ID == "some-org/Custom-Model-7B"
 
43
  print("ok unknown value passed through as a literal HF id")
44
 
45
 
 
 
 
 
 
 
 
 
46
  def test_active_model_reports_stub():
47
  llm = _reload_with("1b")
48
  assert llm.active_model() == "stub", "STUB on -> active_model() is 'stub'"
@@ -50,9 +60,10 @@ def test_active_model_reports_stub():
50
 
51
 
52
  if __name__ == "__main__":
53
- test_default_is_8b()
54
  test_1b_alias()
55
  test_4b_alias()
56
  test_full_id_passthrough()
 
57
  test_active_model_reports_stub()
58
  print("\nAll NAH-9 model-config tests passed.")
 
19
  return importlib.reload(llm)
20
 
21
 
22
+ def test_default_is_v46():
23
  llm = _reload_with(None)
24
+ assert llm.MODEL_ID == "openbmb/MiniCPM-V-4.6"
25
+ assert llm.VISION is True, "default is the multimodal model"
26
+ print("ok default -> MiniCPM-V-4.6 (multimodal)")
27
 
28
 
29
  def test_1b_alias():
 
41
  def test_full_id_passthrough():
42
  llm = _reload_with("some-org/Custom-Model-7B")
43
  assert llm.MODEL_ID == "some-org/Custom-Model-7B"
44
+ assert llm.VISION is False, "a non MiniCPM-V id is not a vision model"
45
  print("ok unknown value passed through as a literal HF id")
46
 
47
 
48
+ def test_vision_detection():
49
+ llm = _reload_with("v46")
50
+ assert llm.MODEL_ID == "openbmb/MiniCPM-V-4.6" and llm.VISION is True
51
+ llm = _reload_with("8b")
52
+ assert llm.MODEL_ID == "openbmb/MiniCPM4.1-8B" and llm.VISION is False
53
+ print("ok vision detection: -V ids -> VISION, text ids -> not")
54
+
55
+
56
  def test_active_model_reports_stub():
57
  llm = _reload_with("1b")
58
  assert llm.active_model() == "stub", "STUB on -> active_model() is 'stub'"
 
60
 
61
 
62
  if __name__ == "__main__":
63
+ test_default_is_v46()
64
  test_1b_alias()
65
  test_4b_alias()
66
  test_full_id_passthrough()
67
+ test_vision_detection()
68
  test_active_model_reports_stub()
69
  print("\nAll NAH-9 model-config tests passed.")