Codex Codex commited on
Commit
f9ab250
·
1 Parent(s): f6c987a

Select llama.cpp GGUF on CPU Basic Spaces

Browse files

Co-authored-by: Codex <chatgpt-codex-connector[bot]@users.noreply.github.com>

DEPLOY.md CHANGED
@@ -1,17 +1,20 @@
1
- # Deploying the Space with ZeroGPU
2
 
3
- The active Hugging Face deployment is a **Gradio ZeroGPU Space** with **Transformers vision** as the default extraction backend.
 
 
 
4
 
5
  This workflow is intentionally fixed:
6
 
7
  1. The Space must stay a Gradio Space, not a Docker Space.
8
- 2. Runtime extraction should use the Transformers backend on ZeroGPU by default.
9
- 3. The extraction call must run behind `@spaces.GPU` so Hugging Face allocates ZeroGPU only while the model is needed.
10
  4. Model files must not be committed to the Space git repo.
11
  5. When the fine-tuned model is ready, replace only the model variables for the active lanes.
12
- 6. The llama.cpp lane is optional and must be enabled explicitly with environment variables.
13
 
14
- Do not change this architecture unless the project intentionally gives up ZeroGPU. To swap models, change `ZEROGPU_MODEL_ID` (or `DEFAULT_HF_REPO` in `src/model_paths.py`) and optional `LLAMACPP_*` for the llama.cpp lane.
15
 
16
  ## 1. Space Metadata
17
 
@@ -33,12 +36,25 @@ pinned: false
33
 
34
  ZeroGPU is Gradio-only on Hugging Face. It is not available for Docker Spaces, which is why the previous Docker + `llama-server` deployment was replaced.
35
 
36
- ## 2. Default Model Serving (Transformers)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
- The production Space path is the fine-tuned Transformers repo:
39
 
40
  ```text
41
- EXTRACTOR_BACKEND=transformers
42
  ZEROGPU_MODEL_ID=build-small-hackathon/blood-test-minicpmv-4_6-medreason
43
  ```
44
 
@@ -61,11 +77,11 @@ transformers.AutoModelForImageTextToText
61
 
62
  This is the correct runtime for PDF/image blood-test uploads on ZeroGPU because the GPU is allocated only inside the decorated worker.
63
 
64
- Aliases `auto`, `zerogpu`, and `zero-gpu` resolve to the same Transformers path in `src/extraction/factory.py`.
65
 
66
- ## 3. Optional llama.cpp Lane
67
 
68
- The app ships a second extraction lane for hackathon badges and GGUF deployment experiments. It is **not enabled by default**.
69
 
70
  ### Why it exists
71
 
@@ -73,9 +89,9 @@ The app ships a second extraction lane for hackathon badges and GGUF deployment
73
  - **Fine-tuned GGUF swap** — deploy a quantized model without changing the Gradio app structure.
74
  - **Text-only fallback** — lighter lane for plain-text lab exports when vision is not needed.
75
 
76
- For normal PDF/image uploads, keep `EXTRACTOR_BACKEND=transformers`.
77
 
78
- ### Enable vision llama.cpp on the Space
79
 
80
  Set these variables in the Space settings:
81
 
@@ -127,9 +143,10 @@ The Docker path failed on free CPU hardware with `OOMKilled` during build. ZeroG
127
  This architecture keeps:
128
 
129
  - Free ZeroGPU eligibility.
 
130
  - No external hosted inference API calls.
131
  - The fine-tuned Transformers runtime on ZeroGPU for PDF/image lab reports.
132
- - An optional llama.cpp / GGUF lane for badges and fine-tuned GGUF deployment.
133
  - A clean model swap by changing `ZEROGPU_MODEL_ID` / `DEFAULT_HF_REPO` and optional `LLAMACPP_*` variables.
134
 
135
  ## 6. Local Development
 
1
+ # Deploying the Space
2
 
3
+ The active Hugging Face deployment is a **Gradio Space** with hardware-aware extraction:
4
+
5
+ - **CPU Basic:** llama.cpp with the base MiniCPM-V GGUF model.
6
+ - **ZeroGPU / GPU:** Transformers vision with the fine-tuned MiniCPM-V checkpoint.
7
 
8
  This workflow is intentionally fixed:
9
 
10
  1. The Space must stay a Gradio Space, not a Docker Space.
11
+ 2. Runtime extraction should use `EXTRACTOR_BACKEND=auto` unless a lane is being forced for testing.
12
+ 3. ZeroGPU extraction calls must run behind `@spaces.GPU`; CPU Basic llama.cpp calls must not require ZeroGPU.
13
  4. Model files must not be committed to the Space git repo.
14
  5. When the fine-tuned model is ready, replace only the model variables for the active lanes.
15
+ 6. The llama.cpp lane is automatic on CPU Basic and can still be enabled explicitly with environment variables.
16
 
17
+ Do not change this architecture unless the project intentionally gives up hardware-aware deployment. To swap models, change `ZEROGPU_MODEL_ID` (or `DEFAULT_HF_REPO` in `src/model_paths.py`) and optional `LLAMACPP_*` for the llama.cpp lane.
18
 
19
  ## 1. Space Metadata
20
 
 
36
 
37
  ZeroGPU is Gradio-only on Hugging Face. It is not available for Docker Spaces, which is why the previous Docker + `llama-server` deployment was replaced.
38
 
39
+ ## 2. Default Model Serving
40
+
41
+ Leave `EXTRACTOR_BACKEND` unset or set it to:
42
+
43
+ ```text
44
+ EXTRACTOR_BACKEND=auto
45
+ ```
46
+
47
+ On **CPU Basic**, `auto` detects `cpu-basic` and selects llama.cpp vision with the base GGUF model:
48
+
49
+ ```text
50
+ LLAMACPP_GGUF_REPO=openbmb/MiniCPM-V-4.6-gguf
51
+ LLAMACPP_MODEL_FILE=MiniCPM-V-4_6-Q4_K_M.gguf
52
+ LLAMACPP_MMPROJ_FILE=mmproj-model-f16.gguf
53
+ ```
54
 
55
+ On **ZeroGPU/GPU**, `auto` selects the fine-tuned Transformers repo:
56
 
57
  ```text
 
58
  ZEROGPU_MODEL_ID=build-small-hackathon/blood-test-minicpmv-4_6-medreason
59
  ```
60
 
 
77
 
78
  This is the correct runtime for PDF/image blood-test uploads on ZeroGPU because the GPU is allocated only inside the decorated worker.
79
 
80
+ Aliases `zerogpu` and `zero-gpu` force the Transformers path in `src/extraction/factory.py`; `auto` is hardware-aware.
81
 
82
+ ## 3. llama.cpp Lane
83
 
84
+ The app ships a second extraction lane for CPU Basic, hackathon badges, and GGUF deployment experiments. It is automatic on CPU Basic.
85
 
86
  ### Why it exists
87
 
 
89
  - **Fine-tuned GGUF swap** — deploy a quantized model without changing the Gradio app structure.
90
  - **Text-only fallback** — lighter lane for plain-text lab exports when vision is not needed.
91
 
92
+ For normal CPU Basic PDF/image uploads, keep `EXTRACTOR_BACKEND=auto` and let the code select llama.cpp vision.
93
 
94
+ ### Force vision llama.cpp on the Space
95
 
96
  Set these variables in the Space settings:
97
 
 
143
  This architecture keeps:
144
 
145
  - Free ZeroGPU eligibility.
146
+ - A CPU Basic fallback that uses llama.cpp + base GGUF instead of Transformers.
147
  - No external hosted inference API calls.
148
  - The fine-tuned Transformers runtime on ZeroGPU for PDF/image lab reports.
149
+ - A llama.cpp / GGUF lane for CPU Basic, badges, and fine-tuned GGUF deployment.
150
  - A clean model swap by changing `ZEROGPU_MODEL_ID` / `DEFAULT_HF_REPO` and optional `LLAMACPP_*` variables.
151
 
152
  ## 6. Local Development
README.md CHANGED
@@ -42,7 +42,7 @@ The breakthrough was to stop teaching the model our schema and teach it general
42
 
43
  ## 🦙 Llama Champion
44
 
45
- The app ships two interchangeable backends behind one interface. The default runs the model through Transformers, and a second backend runs the same MiniCPM-V through the **llama.cpp** runtime, selectable with a single environment variable. We install the prebuilt llama.cpp wheel so the Space builds without a slow source compile, which means the model can run through the llama.cpp runtime when we point it there, while Transformers stays the default for the hosted demo.
46
 
47
  ## 🎨 Off-Brand
48
 
 
42
 
43
  ## 🦙 Llama Champion
44
 
45
+ The app ships two interchangeable backends behind one interface. On CPU Basic Spaces, the default `auto` backend runs the base MiniCPM-V 4.6 GGUF through **llama.cpp** on CPU. On ZeroGPU/GPU, the same `auto` setting uses the fine-tuned Transformers model. We install the prebuilt llama.cpp wheel so the Space builds without a slow source compile, and the operator can still force the GGUF lane explicitly with `EXTRACTOR_BACKEND=llamacpp-gpu` and `LLAMACPP_VISION=1`.
46
 
47
  ## 🎨 Off-Brand
48
 
RUNBOOK.md CHANGED
@@ -1,6 +1,9 @@
1
  # Runbook — Extraction Backends + Fine-Tuned Model Swap
2
 
3
- The active deployment path is **Gradio with Transformers vision on ZeroGPU**.
 
 
 
4
 
5
  This replaced the Docker + `llama-server` path because ZeroGPU is only available for Gradio SDK Spaces. The Docker build was also failing on free CPU hardware with `OOMKilled`.
6
 
@@ -9,9 +12,9 @@ This replaced the Docker + `llama-server` path because ZeroGPU is only available
9
  | Area | Current choice |
10
  |---|---|
11
  | Space SDK | `gradio` |
12
- | Default extraction | Fine-tuned MiniCPM-V 4.6 (`build-small-hackathon/blood-test-minicpmv-4_6-medreason`) |
13
  | ZeroGPU worker | `@spaces.GPU` in `src/extraction/zerogpu_transformers.py` |
14
- | Optional llama.cpp lane | `EXTRACTOR_BACKEND=llamacpp-gpu` (+ `LLAMACPP_VISION=1` for PDF/images) |
15
  | Transformers variables | `ZEROGPU_MODEL_ID`, `ZEROGPU_MAX_NEW_TOKENS`, `ZEROGPU_DOWNSAMPLE_MODE` |
16
  | llama.cpp variables | `LLAMACPP_GGUF_REPO`, `LLAMACPP_MODEL_FILE`, `LLAMACPP_MMPROJ_FILE`, `LLAMACPP_VISION` |
17
  | Extraction backends | `src/extraction/factory.py`, `src/extraction/zerogpu_transformers.py`, `src/extraction/llamacpp_gpu.py` |
@@ -25,8 +28,8 @@ Do not switch the Space back to Docker unless the project intentionally gives up
25
 
26
  | Value | Behavior |
27
  |---|---|
28
- | `transformers` (default) | Fine-tuned MiniCPM-V through Transformers vision |
29
- | `auto`, `zerogpu`, `zero-gpu` | Same as `transformers` |
30
  | `llamacpp-gpu`, `llama-champion` | GGUF through `llama-cpp-python` |
31
  | `local`, `server` | Local `llama-server` HTTP backend |
32
  | `llamacpp` | In-process local GGUF + mmproj |
@@ -35,15 +38,23 @@ Do not switch the Space back to Docker unless the project intentionally gives up
35
  ### Default path (production)
36
 
37
  ```bash
38
- EXTRACTOR_BACKEND=transformers
39
- ZEROGPU_MODEL_ID=build-small-hackathon/blood-test-minicpmv-4_6-medreason
40
  ```
41
 
42
- This is what the HF Space should use for PDF/image blood-test uploads. The env var is optional when it matches `DEFAULT_HF_REPO` in `src/model_paths.py`.
 
 
 
 
 
 
 
 
43
 
44
  ### Optional llama.cpp path
45
 
46
- The llama.cpp lane is **opt-in**. It is not selected automatically.
47
 
48
  **Why keep it:**
49
 
@@ -99,7 +110,7 @@ transformers[torch]==5.7.0
99
  llama-cpp-python
100
  ```
101
 
102
- Transformers runs on ZeroGPU through `@spaces.GPU(duration=120)` (or longer for cold starts). The optional llama.cpp lane uses `@spaces.GPU(duration=600)` because GGUF inference can be slower.
103
 
104
  On Linux x86_64 Spaces, `llama-cpp-python` comes from the prebuilt CPU manylinux wheel:
105
 
@@ -111,10 +122,15 @@ This avoids both the CUDA runtime mismatch that was causing the Space to abort o
111
 
112
  ## Current Model Defaults
113
 
114
- Primary lane:
 
 
 
 
 
 
115
 
116
  ```bash
117
- EXTRACTOR_BACKEND=transformers
118
  ZEROGPU_MODEL_ID=build-small-hackathon/blood-test-minicpmv-4_6-medreason
119
  ```
120
 
@@ -181,4 +197,4 @@ python3 -m py_compile app.py src/*.py src/extraction/*.py
181
  .venv/bin/python -m pytest tests/test_report_pipeline.py tests/test_llamacpp_gpu.py
182
  ```
183
 
184
- Then verify the Space build uses Gradio, not Docker, and that the default backend extracts PDF/image uploads through Transformers.
 
1
  # Runbook — Extraction Backends + Fine-Tuned Model Swap
2
 
3
+ The active deployment path is **hardware-aware Gradio**:
4
+
5
+ - **CPU Basic:** llama.cpp with the base MiniCPM-V GGUF model.
6
+ - **ZeroGPU / GPU:** Transformers vision with the fine-tuned MiniCPM-V checkpoint.
7
 
8
  This replaced the Docker + `llama-server` path because ZeroGPU is only available for Gradio SDK Spaces. The Docker build was also failing on free CPU hardware with `OOMKilled`.
9
 
 
12
  | Area | Current choice |
13
  |---|---|
14
  | Space SDK | `gradio` |
15
+ | Default extraction | `auto`: CPU Basic uses base GGUF through llama.cpp; ZeroGPU/GPU uses fine-tuned Transformers |
16
  | ZeroGPU worker | `@spaces.GPU` in `src/extraction/zerogpu_transformers.py` |
17
+ | llama.cpp lane | Automatic on CPU Basic, or forced with `EXTRACTOR_BACKEND=llamacpp-gpu` (+ `LLAMACPP_VISION=1` for PDF/images) |
18
  | Transformers variables | `ZEROGPU_MODEL_ID`, `ZEROGPU_MAX_NEW_TOKENS`, `ZEROGPU_DOWNSAMPLE_MODE` |
19
  | llama.cpp variables | `LLAMACPP_GGUF_REPO`, `LLAMACPP_MODEL_FILE`, `LLAMACPP_MMPROJ_FILE`, `LLAMACPP_VISION` |
20
  | Extraction backends | `src/extraction/factory.py`, `src/extraction/zerogpu_transformers.py`, `src/extraction/llamacpp_gpu.py` |
 
28
 
29
  | Value | Behavior |
30
  |---|---|
31
+ | unset / `auto` (default) | Hardware-aware: CPU Basic -> llama.cpp base GGUF; otherwise Transformers |
32
+ | `transformers`, `zerogpu`, `zero-gpu` | Force fine-tuned MiniCPM-V through Transformers vision |
33
  | `llamacpp-gpu`, `llama-champion` | GGUF through `llama-cpp-python` |
34
  | `local`, `server` | Local `llama-server` HTTP backend |
35
  | `llamacpp` | In-process local GGUF + mmproj |
 
38
  ### Default path (production)
39
 
40
  ```bash
41
+ # Usually leave EXTRACTOR_BACKEND unset, or set:
42
+ EXTRACTOR_BACKEND=auto
43
  ```
44
 
45
+ On the current CPU Basic Space, `auto` selects llama.cpp vision with the base GGUF defaults:
46
+
47
+ ```bash
48
+ LLAMACPP_GGUF_REPO=openbmb/MiniCPM-V-4.6-gguf
49
+ LLAMACPP_MODEL_FILE=MiniCPM-V-4_6-Q4_K_M.gguf
50
+ LLAMACPP_MMPROJ_FILE=mmproj-model-f16.gguf
51
+ ```
52
+
53
+ When the Space is moved to ZeroGPU/GPU, `auto` selects Transformers and uses `ZEROGPU_MODEL_ID` or the default fine-tuned repo in `src/model_paths.py`.
54
 
55
  ### Optional llama.cpp path
56
 
57
+ The llama.cpp lane is selected automatically on CPU Basic. It can also be forced explicitly.
58
 
59
  **Why keep it:**
60
 
 
110
  llama-cpp-python
111
  ```
112
 
113
+ Transformers runs on ZeroGPU through `@spaces.GPU(duration=120)` (or longer for cold starts). llama.cpp bypasses `@spaces.GPU` on CPU Basic and runs as CPU inference; when forced on GPU/ZeroGPU it uses `@spaces.GPU(duration=600)`.
114
 
115
  On Linux x86_64 Spaces, `llama-cpp-python` comes from the prebuilt CPU manylinux wheel:
116
 
 
122
 
123
  ## Current Model Defaults
124
 
125
+ Auto lane:
126
+
127
+ ```bash
128
+ EXTRACTOR_BACKEND=auto
129
+ ```
130
+
131
+ Transformers lane:
132
 
133
  ```bash
 
134
  ZEROGPU_MODEL_ID=build-small-hackathon/blood-test-minicpmv-4_6-medreason
135
  ```
136
 
 
197
  .venv/bin/python -m pytest tests/test_report_pipeline.py tests/test_llamacpp_gpu.py
198
  ```
199
 
200
+ Then verify the Space build uses Gradio, not Docker. On CPU Basic, the default backend should report `llamacpp-cpu-vision`; on ZeroGPU/GPU it should report the Transformers backend.
requirements.txt CHANGED
@@ -4,13 +4,13 @@ requests==2.32.5
4
  pillow==12.0.0
5
  pymupdf==1.26.6
6
  json-repair==0.60.1
7
- # Local Transformers vision path (Mac + Linux GPU). Default EXTRACTOR_BACKEND=transformers.
8
  torch==2.9.1 ; sys_platform == "darwin" or (sys_platform == "linux" and platform_machine == "x86_64")
9
  transformers[torch]==5.7.0
10
  accelerate==1.12.0
11
  bitsandbytes==0.48.2 ; sys_platform == "linux" and platform_machine == "x86_64"
12
  torchvision==0.24.1 ; sys_platform == "darwin" or (sys_platform == "linux" and platform_machine == "x86_64")
13
  av==16.0.1 ; sys_platform == "linux" and platform_machine == "x86_64"
14
- # CPU fallback path: install the prebuilt manylinux wheel directly to avoid a source build on Spaces.
15
  llama-cpp-python @ https://github.com/abetlen/llama-cpp-python/releases/download/v0.3.28/llama_cpp_python-0.3.28-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl ; sys_platform == "linux" and platform_machine == "x86_64"
16
  llama-cpp-python==0.3.28 ; sys_platform != "linux"
 
4
  pillow==12.0.0
5
  pymupdf==1.26.6
6
  json-repair==0.60.1
7
+ # Transformers vision path (Mac + Linux GPU, and non-CPU-Basic Space auto mode).
8
  torch==2.9.1 ; sys_platform == "darwin" or (sys_platform == "linux" and platform_machine == "x86_64")
9
  transformers[torch]==5.7.0
10
  accelerate==1.12.0
11
  bitsandbytes==0.48.2 ; sys_platform == "linux" and platform_machine == "x86_64"
12
  torchvision==0.24.1 ; sys_platform == "darwin" or (sys_platform == "linux" and platform_machine == "x86_64")
13
  av==16.0.1 ; sys_platform == "linux" and platform_machine == "x86_64"
14
+ # CPU Basic / llama.cpp path: install the prebuilt manylinux wheel directly to avoid a source build on Spaces.
15
  llama-cpp-python @ https://github.com/abetlen/llama-cpp-python/releases/download/v0.3.28/llama_cpp_python-0.3.28-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl ; sys_platform == "linux" and platform_machine == "x86_64"
16
  llama-cpp-python==0.3.28 ; sys_platform != "linux"
src/extraction/__init__.py CHANGED
@@ -1,8 +1,8 @@
1
  """Extraction backends behind one interface.
2
 
3
  `build_extractor()` returns the right backend for the environment:
4
- - **transformers** (default): local OpenBMB MiniCPM-V through Transformers.
5
- - **auto**: same as transformers.
6
  - **llamacpp-gpu** / **llama-champion**: force GGUF through llama.cpp. Set `LLAMACPP_VISION=1`
7
  to run the same PDF/image vision pipeline as Transformers (requires mmproj).
8
  - **local**: local llama-server / llama.cpp backends for local experimentation.
 
1
  """Extraction backends behind one interface.
2
 
3
  `build_extractor()` returns the right backend for the environment:
4
+ - unset / **auto** (default): CPU Basic Spaces use llama.cpp + base GGUF; other runtimes use Transformers.
5
+ - **transformers**: force OpenBMB MiniCPM-V through Transformers.
6
  - **llamacpp-gpu** / **llama-champion**: force GGUF through llama.cpp. Set `LLAMACPP_VISION=1`
7
  to run the same PDF/image vision pipeline as Transformers (requires mmproj).
8
  - **local**: local llama-server / llama.cpp backends for local experimentation.
src/extraction/factory.py CHANGED
@@ -1,8 +1,9 @@
1
  """Backend selection.
2
 
3
  `EXTRACTOR_BACKEND` env:
4
- - `transformers` (default): local OpenBMB MiniCPM-V through Transformers.
5
- - `auto`: same as `transformers`.
 
6
  - `zerogpu` / `zero-gpu`: alias for `transformers`.
7
  - `llamacpp-gpu` / `llama-champion`: llama.cpp GGUF badge path. Add `LLAMACPP_VISION=1`
8
  for PDF/image vision via mmproj.
@@ -21,8 +22,9 @@ from src.extraction.auto import AutoExtractor
21
  from src.extraction.llamacpp_gpu import LlamaCppGPUExtractor
22
  from src.extraction.local_minicpmv import LocalMiniCPMVExtractor
23
  from src.extraction.local_server import LocalServerExtractor
 
24
 
25
- _DEFAULT_BACKEND = "transformers"
26
  _DISABLED_BACKENDS = {"api", "openbmb", "hosted"}
27
 
28
 
@@ -35,7 +37,25 @@ def build_extractor(model: str | None = None) -> Extractor:
35
  "Use EXTRACTOR_BACKEND=transformers for local MiniCPM-V extraction."
36
  )
37
 
38
- if backend in ("auto", "zerogpu", "zero-gpu", "transformers"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  return AutoExtractor(model_id=model)
40
  if backend in ("llamacpp-gpu", "gpu-llamacpp", "llama-champion"):
41
  from src.extraction.llamacpp_vision import llamacpp_vision_enabled
 
1
  """Backend selection.
2
 
3
  `EXTRACTOR_BACKEND` env:
4
+ - unset / `auto` (default): hardware-aware Space selection.
5
+ CPU Basic Spaces use llama.cpp + base MiniCPM-V GGUF; other runtimes use Transformers.
6
+ - `transformers`: local/OpenBMB MiniCPM-V through Transformers.
7
  - `zerogpu` / `zero-gpu`: alias for `transformers`.
8
  - `llamacpp-gpu` / `llama-champion`: llama.cpp GGUF badge path. Add `LLAMACPP_VISION=1`
9
  for PDF/image vision via mmproj.
 
22
  from src.extraction.llamacpp_gpu import LlamaCppGPUExtractor
23
  from src.extraction.local_minicpmv import LocalMiniCPMVExtractor
24
  from src.extraction.local_server import LocalServerExtractor
25
+ from src.space_runtime import configured_space_hardware, is_cpu_basic_space, is_huggingface_space
26
 
27
+ _DEFAULT_BACKEND = "auto"
28
  _DISABLED_BACKENDS = {"api", "openbmb", "hosted"}
29
 
30
 
 
37
  "Use EXTRACTOR_BACKEND=transformers for local MiniCPM-V extraction."
38
  )
39
 
40
+ if backend == "auto":
41
+ if is_cpu_basic_space():
42
+ hardware = configured_space_hardware()
43
+ if hardware:
44
+ os.environ.setdefault("BTE_SPACE_HARDWARE", hardware)
45
+ os.environ.setdefault("LLAMACPP_VISION", "1")
46
+ print(
47
+ "[Blood Test Explainer] CPU Basic Space detected; using llama.cpp "
48
+ "(base MiniCPM-V GGUF)",
49
+ flush=True,
50
+ )
51
+ return LlamaCppGPUExtractor()
52
+ if is_huggingface_space():
53
+ print(
54
+ "[Blood Test Explainer] non-CPU-Basic Space detected; using Transformers extractor",
55
+ flush=True,
56
+ )
57
+ return AutoExtractor(model_id=model)
58
+ if backend in ("zerogpu", "zero-gpu", "transformers"):
59
  return AutoExtractor(model_id=model)
60
  if backend in ("llamacpp-gpu", "gpu-llamacpp", "llama-champion"):
61
  from src.extraction.llamacpp_vision import llamacpp_vision_enabled
src/extraction/llamacpp_gpu.py CHANGED
@@ -39,6 +39,7 @@ from src.openbmb_client import (
39
  _parse_json_response,
40
  summarize_document_parts,
41
  )
 
42
 
43
  DEFAULT_GGUF_REPO = "openbmb/MiniCPM-V-4.6-gguf"
44
  DEFAULT_MODEL_FILE = "MiniCPM-V-4_6-Q4_K_M.gguf"
@@ -69,6 +70,7 @@ class LlamaCppGPUExtractor:
69
  self.n_ctx = int(os.getenv("LLAMACPP_N_CTX", "8192"))
70
  self.n_gpu_layers = int(os.getenv("LLAMACPP_N_GPU_LAYERS", "0"))
71
  self.vision_enabled = llamacpp_vision_enabled()
 
72
  if self.vision_enabled and not self.mmproj_file:
73
  raise ValueError("LLAMACPP_VISION=1 requires LLAMACPP_MMPROJ_FILE.")
74
 
@@ -76,7 +78,12 @@ class LlamaCppGPUExtractor:
76
  parts = document_to_payload_parts(file_path, max_pages=max_pages)
77
  started = time.perf_counter()
78
  if self.vision_enabled:
79
- raw = _run_llamacpp_vision_generation(
 
 
 
 
 
80
  parts=parts,
81
  repo=self.repo,
82
  model_file=self.model_file,
@@ -86,11 +93,16 @@ class LlamaCppGPUExtractor:
86
  n_ctx=self.n_ctx,
87
  n_gpu_layers=self.n_gpu_layers,
88
  )
89
- backend = "llamacpp-gpu-vision"
90
  composed_prompt = None
91
  else:
92
  prompt_text = _compose_prompt(parts)
93
- raw = _run_llamacpp_generation(
 
 
 
 
 
94
  prompt_text=prompt_text,
95
  repo=self.repo,
96
  model_file=self.model_file,
@@ -98,7 +110,7 @@ class LlamaCppGPUExtractor:
98
  n_ctx=self.n_ctx,
99
  n_gpu_layers=self.n_gpu_layers,
100
  )
101
- backend = "llamacpp-gpu"
102
  composed_prompt = prompt_text
103
 
104
  duration_ms = int((time.perf_counter() - started) * 1000)
@@ -108,6 +120,7 @@ class LlamaCppGPUExtractor:
108
  "repo": self.repo,
109
  "model": self.model_file,
110
  "vision_enabled": self.vision_enabled,
 
111
  "document_parts": len(parts),
112
  "max_pages": max_pages,
113
  "extraction_prompt": EXTRACTION_PROMPT,
@@ -177,6 +190,28 @@ def _run_llamacpp_vision_generation(
177
  max_tokens: int,
178
  n_ctx: int,
179
  n_gpu_layers: int,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  ) -> str:
181
  try:
182
  model_path = download_hf_file(repo, model_file)
@@ -217,6 +252,24 @@ def _run_llamacpp_generation(
217
  max_tokens: int,
218
  n_ctx: int,
219
  n_gpu_layers: int,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  ) -> str:
221
  try:
222
  model_path = download_hf_file(repo, model_file)
 
39
  _parse_json_response,
40
  summarize_document_parts,
41
  )
42
+ from src.space_runtime import is_cpu_basic_space
43
 
44
  DEFAULT_GGUF_REPO = "openbmb/MiniCPM-V-4.6-gguf"
45
  DEFAULT_MODEL_FILE = "MiniCPM-V-4_6-Q4_K_M.gguf"
 
70
  self.n_ctx = int(os.getenv("LLAMACPP_N_CTX", "8192"))
71
  self.n_gpu_layers = int(os.getenv("LLAMACPP_N_GPU_LAYERS", "0"))
72
  self.vision_enabled = llamacpp_vision_enabled()
73
+ self.use_spaces_gpu = not is_cpu_basic_space()
74
  if self.vision_enabled and not self.mmproj_file:
75
  raise ValueError("LLAMACPP_VISION=1 requires LLAMACPP_MMPROJ_FILE.")
76
 
 
78
  parts = document_to_payload_parts(file_path, max_pages=max_pages)
79
  started = time.perf_counter()
80
  if self.vision_enabled:
81
+ runner = (
82
+ _run_llamacpp_vision_generation
83
+ if self.use_spaces_gpu
84
+ else _run_llamacpp_vision_generation_cpu
85
+ )
86
+ raw = runner(
87
  parts=parts,
88
  repo=self.repo,
89
  model_file=self.model_file,
 
93
  n_ctx=self.n_ctx,
94
  n_gpu_layers=self.n_gpu_layers,
95
  )
96
+ backend = "llamacpp-gpu-vision" if self.use_spaces_gpu else "llamacpp-cpu-vision"
97
  composed_prompt = None
98
  else:
99
  prompt_text = _compose_prompt(parts)
100
+ runner = (
101
+ _run_llamacpp_generation
102
+ if self.use_spaces_gpu
103
+ else _run_llamacpp_generation_cpu
104
+ )
105
+ raw = runner(
106
  prompt_text=prompt_text,
107
  repo=self.repo,
108
  model_file=self.model_file,
 
110
  n_ctx=self.n_ctx,
111
  n_gpu_layers=self.n_gpu_layers,
112
  )
113
+ backend = "llamacpp-gpu" if self.use_spaces_gpu else "llamacpp-cpu"
114
  composed_prompt = prompt_text
115
 
116
  duration_ms = int((time.perf_counter() - started) * 1000)
 
120
  "repo": self.repo,
121
  "model": self.model_file,
122
  "vision_enabled": self.vision_enabled,
123
+ "spaces_gpu": self.use_spaces_gpu,
124
  "document_parts": len(parts),
125
  "max_pages": max_pages,
126
  "extraction_prompt": EXTRACTION_PROMPT,
 
190
  max_tokens: int,
191
  n_ctx: int,
192
  n_gpu_layers: int,
193
+ ) -> str:
194
+ return _run_llamacpp_vision_generation_cpu(
195
+ parts=parts,
196
+ repo=repo,
197
+ model_file=model_file,
198
+ mmproj_file=mmproj_file,
199
+ chat_handler=chat_handler,
200
+ max_tokens=max_tokens,
201
+ n_ctx=n_ctx,
202
+ n_gpu_layers=n_gpu_layers,
203
+ )
204
+
205
+
206
+ def _run_llamacpp_vision_generation_cpu(
207
+ parts: list[dict[str, Any]],
208
+ repo: str,
209
+ model_file: str,
210
+ mmproj_file: str,
211
+ chat_handler: str,
212
+ max_tokens: int,
213
+ n_ctx: int,
214
+ n_gpu_layers: int,
215
  ) -> str:
216
  try:
217
  model_path = download_hf_file(repo, model_file)
 
252
  max_tokens: int,
253
  n_ctx: int,
254
  n_gpu_layers: int,
255
+ ) -> str:
256
+ return _run_llamacpp_generation_cpu(
257
+ prompt_text=prompt_text,
258
+ repo=repo,
259
+ model_file=model_file,
260
+ max_tokens=max_tokens,
261
+ n_ctx=n_ctx,
262
+ n_gpu_layers=n_gpu_layers,
263
+ )
264
+
265
+
266
+ def _run_llamacpp_generation_cpu(
267
+ prompt_text: str,
268
+ repo: str,
269
+ model_file: str,
270
+ max_tokens: int,
271
+ n_ctx: int,
272
+ n_gpu_layers: int,
273
  ) -> str:
274
  try:
275
  model_path = download_hf_file(repo, model_file)
src/space_runtime.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helpers for hardware-aware Hugging Face Space behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from functools import lru_cache
7
+
8
+
9
+ _HARDWARE_ENV_KEYS = (
10
+ "BTE_SPACE_HARDWARE",
11
+ "SPACE_HARDWARE",
12
+ "HF_SPACE_HARDWARE",
13
+ )
14
+
15
+
16
+ def is_huggingface_space() -> bool:
17
+ return bool(os.getenv("SPACE_ID") or os.getenv("SPACE_HOST"))
18
+
19
+
20
+ def configured_space_hardware() -> str | None:
21
+ for key in _HARDWARE_ENV_KEYS:
22
+ value = os.getenv(key, "").strip().lower()
23
+ if value:
24
+ return value
25
+ return _hub_space_hardware()
26
+
27
+
28
+ def is_cpu_basic_space() -> bool:
29
+ return configured_space_hardware() == "cpu-basic"
30
+
31
+
32
+ @lru_cache(maxsize=8)
33
+ def _hub_space_hardware() -> str | None:
34
+ repo_id = os.getenv("SPACE_ID", "").strip()
35
+ if not repo_id:
36
+ return None
37
+ try:
38
+ from huggingface_hub import HfApi
39
+
40
+ info = HfApi().space_info(repo_id=repo_id)
41
+ runtime = getattr(info, "runtime", None)
42
+ hardware = getattr(runtime, "hardware", None)
43
+ if hardware:
44
+ return str(hardware).strip().lower()
45
+ except Exception:
46
+ return None
47
+ return None
tests/test_extraction_factory.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+
6
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
7
+
8
+ from src.extraction.factory import build_extractor
9
+
10
+
11
+ def test_auto_uses_llamacpp_on_cpu_basic_space(monkeypatch):
12
+ monkeypatch.delenv("EXTRACTOR_BACKEND", raising=False)
13
+ monkeypatch.delenv("LLAMACPP_VISION", raising=False)
14
+ monkeypatch.setenv("BTE_SPACE_HARDWARE", "cpu-basic")
15
+
16
+ with patch("src.extraction.factory.LlamaCppGPUExtractor") as llama_cls:
17
+ extractor = build_extractor()
18
+
19
+ llama_cls.assert_called_once_with()
20
+ assert extractor is llama_cls.return_value
21
+ assert os.environ["LLAMACPP_VISION"] == "1"
22
+
23
+
24
+ def test_auto_uses_transformers_when_hardware_is_not_cpu_basic(monkeypatch):
25
+ monkeypatch.delenv("EXTRACTOR_BACKEND", raising=False)
26
+ monkeypatch.setenv("BTE_SPACE_HARDWARE", "zero-a10g")
27
+
28
+ with patch("src.extraction.factory.AutoExtractor") as auto_cls:
29
+ extractor = build_extractor(model="demo/model")
30
+
31
+ auto_cls.assert_called_once_with(model_id="demo/model")
32
+ assert extractor is auto_cls.return_value
tests/test_llamacpp_gpu.py CHANGED
@@ -37,6 +37,7 @@ def test_compose_prompt_keeps_text_files():
37
 
38
  def test_extract_uses_vision_generation_when_enabled(monkeypatch):
39
  monkeypatch.setenv("LLAMACPP_VISION", "1")
 
40
  image_parts = [{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}]
41
 
42
  with patch(
@@ -55,10 +56,34 @@ def test_extract_uses_vision_generation_when_enabled(monkeypatch):
55
  assert result.request_summary["backend"] == "llamacpp-gpu-vision"
56
  assert result.request_summary["vision_enabled"] is True
57
  assert result.request_summary["mmproj"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
 
60
  def test_extract_uses_text_generation_when_vision_disabled(monkeypatch):
61
  monkeypatch.delenv("LLAMACPP_VISION", raising=False)
 
62
  text_parts = [{"type": "text", "text": "WBC 6.5"}]
63
 
64
  with patch(
 
37
 
38
  def test_extract_uses_vision_generation_when_enabled(monkeypatch):
39
  monkeypatch.setenv("LLAMACPP_VISION", "1")
40
+ monkeypatch.delenv("BTE_SPACE_HARDWARE", raising=False)
41
  image_parts = [{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}]
42
 
43
  with patch(
 
56
  assert result.request_summary["backend"] == "llamacpp-gpu-vision"
57
  assert result.request_summary["vision_enabled"] is True
58
  assert result.request_summary["mmproj"]
59
+ assert result.request_summary["spaces_gpu"] is True
60
+
61
+
62
+ def test_cpu_basic_uses_plain_cpu_vision_runner(monkeypatch):
63
+ monkeypatch.setenv("BTE_SPACE_HARDWARE", "cpu-basic")
64
+ monkeypatch.setenv("LLAMACPP_VISION", "1")
65
+ image_parts = [{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}]
66
+
67
+ with patch(
68
+ "src.extraction.llamacpp_gpu.document_to_payload_parts",
69
+ return_value=image_parts,
70
+ ), patch(
71
+ "src.extraction.llamacpp_gpu._run_llamacpp_vision_generation_cpu",
72
+ return_value='{"patient":{},"tests":[],"notes":[]}',
73
+ ) as cpu_run, patch(
74
+ "src.extraction.llamacpp_gpu._run_llamacpp_vision_generation",
75
+ ) as gpu_run:
76
+ result = LlamaCppGPUExtractor().extract("/tmp/report.pdf")
77
+
78
+ cpu_run.assert_called_once()
79
+ gpu_run.assert_not_called()
80
+ assert result.request_summary["backend"] == "llamacpp-cpu-vision"
81
+ assert result.request_summary["spaces_gpu"] is False
82
 
83
 
84
  def test_extract_uses_text_generation_when_vision_disabled(monkeypatch):
85
  monkeypatch.delenv("LLAMACPP_VISION", raising=False)
86
+ monkeypatch.delenv("BTE_SPACE_HARDWARE", raising=False)
87
  text_parts = [{"type": "text", "text": "WBC 6.5"}]
88
 
89
  with patch(