J.B-Lin commited on
Commit
4a3e24c
·
2 Parent(s): 694082cf10673a

Merge branch 'main' of https://huggingface.co/spaces/build-small-hackathon/PregoPal

Browse files
_test_inference.py CHANGED
@@ -1,17 +1,38 @@
1
- import sys, json, httpx, os
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  os.environ["PYTHONIOENCODING"] = "utf-8"
 
 
4
  URL = "https://andrew-jiabin--prego-pal-minicpm-serve.modal.run"
5
 
 
 
 
6
  payload = {
7
- "model": "minicpm",
8
  "messages": [{"role": "user", "content": "你好,请用一句话回答:1+1等于几?"}],
9
  "max_tokens": 50,
10
  "temperature": 0.7
11
  }
12
 
13
- print("Sending request to:", URL + "/v1/chat/completions")
14
- print("This may take 2-8 minutes for cold start...")
 
 
 
 
15
 
16
  try:
17
  r = httpx.post(
@@ -19,14 +40,18 @@ try:
19
  json=payload,
20
  timeout=600 # 10 minutes for cold start
21
  )
22
- print(f"\nStatus: {r.status_code}")
23
  if r.status_code == 200:
24
  data = r.json()
25
  content = data["choices"][0]["message"]["content"]
26
- print(f"\n=== MODEL RESPONSE ===\n{content}\n======================")
 
 
27
  else:
28
- print(f"\nResponse body (first 500 chars):\n{r.text[:500]}")
 
29
  except httpx.TimeoutException:
30
- print("\nRequest timed out (600s) - container still initializing, check modal logs")
 
31
  except Exception as e:
32
- print(f"\nError: {e}")
 
1
+ """
2
+ Test inference endpoint for PregoPal MiniCPM-o API on Modal
3
+
4
+ Usage:
5
+ python _test_inference.py
6
+
7
+ # Or with custom URL:
8
+ python _test_inference.py https://andrew-jiabin--prego-pal-minicpm-serve.modal.run
9
+ """
10
+ import sys
11
+ import json
12
+ import httpx
13
+ import os
14
 
15
  os.environ["PYTHONIOENCODING"] = "utf-8"
16
+
17
+ # Default URL - update after deployment
18
  URL = "https://andrew-jiabin--prego-pal-minicpm-serve.modal.run"
19
 
20
+ if len(sys.argv) > 1:
21
+ URL = sys.argv[1].rstrip("/")
22
+
23
  payload = {
24
+ "model": "MiniCPM-o-4_5",
25
  "messages": [{"role": "user", "content": "你好,请用一句话回答:1+1等于几?"}],
26
  "max_tokens": 50,
27
  "temperature": 0.7
28
  }
29
 
30
+ print("=" * 60)
31
+ print("PregoPal MiniCPM-o API 测试")
32
+ print("=" * 60)
33
+ print(f"URL: {URL}/v1/chat/completions")
34
+ print("注意:冷启动需要 2-10 分钟加载模型...")
35
+ print()
36
 
37
  try:
38
  r = httpx.post(
 
40
  json=payload,
41
  timeout=600 # 10 minutes for cold start
42
  )
43
+ print(f"状态码: {r.status_code}")
44
  if r.status_code == 200:
45
  data = r.json()
46
  content = data["choices"][0]["message"]["content"]
47
+ print(f"\n=== 模型响应 ===")
48
+ print(content)
49
+ print("=" * 20)
50
  else:
51
+ print(f"\n响应 (500字符):")
52
+ print(r.text[:500])
53
  except httpx.TimeoutException:
54
+ print("\n请求超时 (600s) - 容器仍在初始化,检查 Modal 日志")
55
+ print(" modal app logs $(modal app list | grep prego-pal | awk '{print $1}')")
56
  except Exception as e:
57
+ print(f"\n错误: {e}")
modal_deploy/README.md CHANGED
@@ -9,8 +9,9 @@
9
  ```
10
  用户请求 → FastAPI (ASGI) → llama-server (OpenAI 兼容) → MiniCPM-o-4_5 GGUF
11
 
12
- 纯文本: /v1/chat/completions
13
- 多模态: /v1/multimodal/chat (通过 llama-mtmd-cli)
 
14
  ```
15
 
16
  ## 前置条件
@@ -21,26 +22,26 @@
21
 
22
  ## 模型文件
23
 
24
- 需要 10 个 GGUF 文件( 4.3 GB,从 HuggingFace 下载
 
 
 
 
 
 
 
 
 
 
25
 
26
  ```bash
27
- # 下载本地
28
- python _download_models.py # PregoPal 根目录
29
 
30
- # 上传到 Modal Volume
31
- modal volume put minicpm-o-4_5-models ../models/MiniCPM-o-4_5-gguf /
32
  ```
33
 
34
- 模型文件列表:
35
- | 文件 | 大小 | 用途 |
36
- |------|------|------|
37
- | `MiniCPM-o-4_5-Q4_K_M.gguf` | 688 MB | 主模型(文本推理) |
38
- | `vision/MiniCPM-o-4_5-vision-F16.gguf` | 1044 MB | 视觉投影层 |
39
- | `audio/MiniCPM-o-4_5-audio-F16.gguf` | 630 MB | 音频投影层 |
40
- | `tts/MiniCPM-o-4_5-tts-F16.gguf` | 1104 MB | TTS 语音合成 |
41
- | `tts/MiniCPM-o-4_5-projector-F16.gguf` | 14 MB | 投影层 |
42
- | `token2wav-gguf/*.gguf` (5 文件) | 875 MB | 语音合成 token→wav |
43
-
44
  ## 部署
45
 
46
  ```bash
@@ -85,11 +86,13 @@ Content-Type: application/json
85
  ```
86
 
87
  ### 4. 多模态对话(图片理解)
 
88
  ```bash
89
- POST /v1/multimodal/chat
90
  Content-Type: application/json
91
 
92
  {
 
93
  "messages": [
94
  {"role": "user", "content": [
95
  {"type": "text", "text": "这张图片里有什么食物?"},
@@ -153,4 +156,5 @@ def ask_minicpm(prompt: str) -> str:
153
  1. **冷启动**:无请求 >5 分钟后容器关闭,下次请求需等待 ~30-60 秒(模型加载)
154
  2. **成本**:A100 约 $1.10/小时,按实际使用计费
155
  3. **模型上传**:Volume 是持久化的,模型只需上传一次
156
- 4. **并发**:`allow_concurrent_inputs=10` 支持 10 个并发请求
 
 
9
  ```
10
  用户请求 → FastAPI (ASGI) → llama-server (OpenAI 兼容) → MiniCPM-o-4_5 GGUF
11
 
12
+ /v1/chat/completions (文本 + 图片多模态)
13
+ /v1/embeddings
14
+ /health
15
  ```
16
 
17
  ## 前置条件
 
22
 
23
  ## 模型文件
24
 
25
+ 部署需要 3核心 GGUF 文件(Volume 中需包含):
26
+
27
+ | 文件 | 大小 | 用途 |
28
+ |------|------|------|
29
+ | `MiniCPM-o-4_5-Q4_K_M.gguf` | ~12 GB | 主模型(文本推理) |
30
+ | `vision/MiniCPM-o-4_5-vision-F16.gguf` | ~1 GB | 视觉投影层(可选,用于图片理解) |
31
+ | `audio/MiniCPM-o-4_5-audio-F16.gguf` | ~630 MB | 音频投影层(当前未使用) |
32
+
33
+ ⚠️ **关键变更**:`--mmproj` 只传 vision 投影层,**不能**传入多个 .gguf 文件。旧代码使用 `find_mmproj_files()` 递归搜索导致传入 9 个文件而崩溃。
34
+
35
+ ### 上传步骤
36
 
37
  ```bash
38
+ # 上传模型 Modal Volume(只需一次)
39
+ modal volume put minicpm-o-4_5-models /path/to/models/MiniCPM-o-4_5-gguf /
40
 
41
+ # 验证
42
+ modal volume ls minicpm-o-4_5-models /
43
  ```
44
 
 
 
 
 
 
 
 
 
 
 
45
  ## 部署
46
 
47
  ```bash
 
86
  ```
87
 
88
  ### 4. 多模态对话(图片理解)
89
+ llama-server (b4690+) 原生支持 OpenAI 多模态格式,通过 `/v1/chat/completions` 即可:
90
  ```bash
91
+ POST /v1/chat/completions
92
  Content-Type: application/json
93
 
94
  {
95
+ "model": "MiniCPM-o-4_5",
96
  "messages": [
97
  {"role": "user", "content": [
98
  {"type": "text", "text": "这张图片里有什么食物?"},
 
156
  1. **冷启动**:无请求 >5 分钟后容器关闭,下次请求需等待 ~30-60 秒(模型加载)
157
  2. **成本**:A100 约 $1.10/小时,按实际使用计费
158
  3. **模型上传**:Volume 是持久化的,模型只需上传一次
159
+ 4. **并发**:`@modal.concurrent(max_inputs=10)` 支持 10 个并发请求
160
+ 5. **修复说明**:旧 deploy.py 的 `find_mmproj_files()` 递归搜索到 9 个 .gguf 文件并全部作为 `--mmproj` 传入,导致 llama-server 启动失败。新版已明确指定 `--mmproj` 使用 vision 投影层(仅一个文件)。
modal_deploy/client.py CHANGED
@@ -145,7 +145,7 @@ class MiniCPMClient:
145
  "max_tokens": max_tokens or self.default_max_tokens,
146
  "temperature": temperature or self.default_temperature,
147
  }
148
- return self._post("/v1/multimodal/chat", body)
149
 
150
  def describe_image(self, image_base64: str, image_format: str = "jpeg") -> str:
151
  """简化调用:描述图片内容"""
@@ -198,5 +198,5 @@ SYSTEM_PROMPTS = {
198
  2. 与孕期推荐标准的对比
199
  3. 需要补充或调整的方向
200
  4. 具体的一周饮食改善建议
201
- 用结构化格式输出,便于前端可视化""",
202
- }
 
145
  "max_tokens": max_tokens or self.default_max_tokens,
146
  "temperature": temperature or self.default_temperature,
147
  }
148
+ return self._post("/v1/chat/completions", body)
149
 
150
  def describe_image(self, image_base64: str, image_format: str = "jpeg") -> str:
151
  """简化调用:描述图片内容"""
 
198
  2. 与孕期推荐标准的对比
199
  3. 需要补充或调整的方向
200
  4. 具体的一周饮食改善建议
201
+ 5. 用结构化格式输出,便于前端可视化""",
202
+ }
modal_deploy/deploy.py CHANGED
@@ -1,120 +1,132 @@
1
  """
2
  Modal deployment for MiniCPM-o-4_5 via llama.cpp server
3
- Serves OpenAI-compatible chat API + multimodal (text/image/audio) endpoints
 
 
 
 
 
 
 
 
 
 
 
 
4
  """
5
  import os
6
  import sys
7
  import subprocess
8
  import modal
9
- from modal import Image, App, Volume, Secret, asgi_app
10
 
11
- # ── constants ──────────────────────────────────────────────
12
- MODEL_DIR = "/models"
13
- MODEL_SUBDIR = f"{MODEL_DIR}/MiniCPM-o-4_5-gguf"
14
- MODEL_NAME = "MiniCPM-o-4_5"
15
- # Model files expected in the volume
16
- MAIN_MODEL = f"{MODEL_SUBDIR}/MiniCPM-o-4_5-Q4_K_M.gguf"
17
- # Projection files are auto-discovered by find_mmproj_files()
18
 
19
- # Image with llama.cpp compiled
20
- llamacpp_image = (
21
  Image.debian_slim(python_version="3.11")
22
- .apt_install("curl", "git", "build-essential", "cmake", "libcurl4-openssl-dev",
23
- "software-properties-common", "wget")
 
 
24
  .run_commands(
25
- # Install CUDA toolkit (including nvcc + runtime libraries) for GPU inference on A100
26
  "wget -q https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64/cuda-keyring_1.1-1_all.deb",
27
  "dpkg -i cuda-keyring_1.1-1_all.deb",
28
  "apt-get update -qq",
29
  "apt-get install -y -qq cuda-toolkit-12-1 cuda-compiler-12-1 2>&1 | tail -5",
30
- # Ensure CUDA runtime libs are discoverable at runtime
31
- "echo '/usr/local/cuda-12.1/lib64' >> /etc/ld.so.conf.d/cuda.conf",
32
- "ldconfig",
33
  )
34
- .pip_install("fastapi", "uvicorn", "httpx", "numpy", "Pillow", "soundfile")
35
  .env({"CUDACXX": "/usr/local/cuda/bin/nvcc",
36
  "CUDA_HOME": "/usr/local/cuda-12.1",
37
  "LD_LIBRARY_PATH": "/usr/local/cuda-12.1/lib64"})
 
 
 
 
 
38
  .run_commands(
39
  "git clone --depth 1 https://github.com/ggerganov/llama.cpp /llama.cpp",
40
- "cd /llama.cpp && cmake -B build -DBUILD_SHARED_LIBS=OFF -DCMAKE_BUILD_TYPE=Release -DGGML_CUDA=ON -DLLAMA_CURL=ON -DLLAMA_BUILD_SERVER=ON -DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF",
41
- "cd /llama.cpp && cmake --build build --config Release -j $(nproc) --target llama-server llama-mtmd-cli",
 
 
 
42
  )
43
  )
44
 
 
 
 
 
 
 
 
 
 
 
45
  # Volume to store models (persisted across restarts)
46
  model_volume = Volume.from_name("minicpm-o-4_5-models", create_if_missing=True)
47
 
 
48
  app = App("prego-pal-minicpm")
49
 
50
- # ── Helpers ────────────────────────────────────────���────────
51
-
52
- def find_mmproj_files(model_dir: str) -> list[str]:
53
- """Locate ALL mmproj GGUF files in model directory structure.
54
-
55
- Scans subdirectories (vision/, audio/, tts/) for .gguf files
56
- that are NOT the main language model.
57
- """
58
- mmproj = []
59
- main_basename = os.path.basename(MAIN_MODEL)
60
- for root, dirs, files in os.walk(model_dir):
61
- for f in sorted(files):
62
- if f.endswith(".gguf") and f != main_basename:
63
- mmproj.append(os.path.join(root, f))
64
- return mmproj
65
-
66
 
67
  def build_server_args(model_dir: str, port: int = 8080) -> list[str]:
68
  """Construct llama-server command line arguments.
69
 
70
- All model paths are derived from the model_dir parameter,
71
- not from hardcoded constants.
 
 
 
72
  """
73
- main_model_path = os.path.join(model_dir, "MiniCPM-o-4_5-Q4_K_M.gguf")
 
 
74
  args = [
75
  "/llama.cpp/build/bin/llama-server",
76
  "-m", main_model_path,
77
  "--host", "0.0.0.0",
78
  "--port", str(port),
79
- "-ngl", "99", # offload all layers to GPU if available
80
- "-c", "8192", # context size
 
81
  ]
82
 
83
- # Attach mmproj files for multimodal support (vision + audio + tts)
84
- mmproj_files = find_mmproj_files(model_dir)
85
- for mf in mmproj_files:
86
- args.extend(["--mmproj", mf])
 
 
 
 
 
 
 
87
 
88
  return args
89
 
90
 
91
- def find_vision_mmproj(model_dir: str) -> str | None:
92
- """Find the vision projection GGUF file."""
93
- vision_dir = os.path.join(model_dir, "vision")
94
- if not os.path.isdir(vision_dir):
95
- return None
96
- for f in sorted(os.listdir(vision_dir)):
97
- if f.endswith(".gguf"):
98
- return os.path.join(vision_dir, f)
99
- return None
100
-
101
-
102
- # ── Entrypoint / API ────────────────────────────────────────
103
 
104
  @app.function(
105
- image=llamacpp_image,
106
  volumes={MODEL_DIR: model_volume},
107
  scaledown_window=300, # shutdown after 5min idle
108
- gpu="A100", # MiniCPM-o-4_5 needs GPU
109
- timeout=600,
110
  )
 
111
  @asgi_app()
112
  def serve():
113
- """FastAPI app wrapping llama-server for OpenAI-compatible endpoints.
114
-
115
- NOTE: @modal.concurrent cannot be combined with @asgi_app().
116
- Modal handles concurrency at the function level for ASGI apps automatically.
117
- """
118
  import asyncio
119
  import httpx
120
  from fastapi import FastAPI, Request
@@ -145,21 +157,30 @@ def serve():
145
 
146
  llama_url = f"http://127.0.0.1:{server_port}"
147
 
148
- # Wait for server to be ready
149
- async def wait_for_server(timeout: float = 300.0):
150
- async with httpx.AsyncClient() as client:
151
  start = asyncio.get_event_loop().time()
152
  while True:
153
  try:
154
- r = await client.get(f"{llama_url}/health", timeout=5)
155
  if r.status_code == 200:
156
  print("[PregoPal] llama-server ready!")
157
  return
158
- except Exception:
159
- pass
160
- if asyncio.get_event_loop().time() - start > timeout:
161
- raise RuntimeError("llama-server startup timed out")
162
- await asyncio.sleep(1)
 
 
 
 
 
 
 
 
 
163
 
164
  @web_app.on_event("startup")
165
  async def startup():
@@ -174,15 +195,22 @@ def serve():
174
  except subprocess.TimeoutExpired:
175
  proc.kill()
176
 
177
- # ── Proxy endpoints to llama-server ──────────────────
 
 
178
 
179
  @web_app.post("/v1/chat/completions")
180
  async def chat_completions(request: Request):
181
- """OpenAI-compatible chat completions (text)."""
 
 
 
 
 
 
182
  body = await request.json()
183
  async with httpx.AsyncClient(timeout=300) as client:
184
  r = await client.post(f"{llama_url}/v1/chat/completions", json=body)
185
- # Stream if requested
186
  if body.get("stream", False):
187
  return StreamingResponse(
188
  r.aiter_bytes(),
@@ -211,150 +239,32 @@ def serve():
211
  r = await client.get(f"{llama_url}/v1/models")
212
  return JSONResponse(r.json(), status_code=r.status_code)
213
 
214
- # ── Multimodal endpoint ──────────────────────────────
215
-
216
- @web_app.post("/v1/multimodal/chat")
217
- async def multimodal_chat(request: Request):
218
- """
219
- Custom multimodal chat endpoint.
220
-
221
- NOTE: llama-server (>=b4690) natively supports multimodal via /v1/chat/completions
222
- with content parts including image_url. Only use llama-mtmd-cli as fallback
223
- when the direct API doesn't support the modality needed.
224
-
225
- For now, we proxy to llama-server directly and let it handle multimodal
226
- if it's a new enough build. If that fails, fall back to llama-mtmd-cli.
227
-
228
- Request format:
229
- {
230
- "messages": [
231
- {"role": "user", "content": [
232
- {"type": "text", "text": "Describe this image"},
233
- {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}
234
- ]}
235
- ],
236
- "max_tokens": 1024,
237
- "temperature": 0.7
238
- }
239
- """
240
- import json as json_mod
241
-
242
- body = await request.json()
243
-
244
- # First try: proxy directly to llama-server (it may support multimodal natively)
245
- try:
246
- async with httpx.AsyncClient(timeout=300) as client:
247
- r = await client.post(f"{llama_url}/v1/chat/completions", json=body)
248
- if r.status_code == 200:
249
- if body.get("stream", False):
250
- return StreamingResponse(
251
- r.aiter_bytes(),
252
- media_type="text/event-stream",
253
- headers=dict(r.headers),
254
- )
255
- return JSONResponse(r.json(), status_code=r.status_code)
256
- except Exception:
257
- pass
258
-
259
- # Fallback: use llama-mtmd-cli for multimodal if native path fails
260
- import base64
261
- import tempfile
262
-
263
- messages = body.get("messages", [])
264
- max_tokens = body.get("max_tokens", 1024)
265
- temperature = body.get("temperature", 0.7)
266
-
267
- # Extract text prompt and image
268
- prompt_parts = []
269
- image_path = None
270
-
271
- for msg in messages:
272
- if msg["role"] == "user":
273
- content = msg["content"]
274
- if isinstance(content, str):
275
- prompt_parts.append(content)
276
- elif isinstance(content, list):
277
- for item in content:
278
- if item["type"] == "text":
279
- prompt_parts.append(item["text"])
280
- elif item["type"] == "image_url":
281
- data_url = item["image_url"]["url"]
282
- if data_url.startswith("data:image/"):
283
- # Parse base64 image
284
- header, b64 = data_url.split(",", 1)
285
- fmt = header.split("/")[1].split(";")[0]
286
- img_data = base64.b64decode(b64)
287
- fd, image_path = tempfile.mkstemp(suffix=f".{fmt}")
288
- os.close(fd)
289
- with open(image_path, "wb") as f:
290
- f.write(img_data)
291
-
292
- prompt = "\n".join(prompt_parts)
293
-
294
- # Dynamically find vision mmproj
295
- vision_mmproj = find_vision_mmproj(MODEL_SUBDIR)
296
- if not vision_mmproj:
297
- return JSONResponse(
298
- {"error": "Vision projection model not found"}, status_code=500
299
- )
300
-
301
- # Build llama-mtmd-cli command (without --json-schema which may not exist)
302
- cmd = [
303
- "/llama.cpp/build/bin/llama-mtmd-cli",
304
- "-m", os.path.join(MODEL_SUBDIR, "MiniCPM-o-4_5-Q4_K_M.gguf"),
305
- "--mmproj", vision_mmproj,
306
- "-ngl", "99",
307
- "-c", "8192",
308
- "-n", str(max_tokens),
309
- "--temp", str(temperature),
310
- "-p", prompt,
311
- ]
312
-
313
- if image_path:
314
- cmd.extend(["--image", image_path])
315
-
316
- try:
317
- r_proc = await asyncio.create_subprocess_exec(
318
- *cmd,
319
- stdout=asyncio.subprocess.PIPE,
320
- stderr=asyncio.subprocess.PIPE,
321
- )
322
- stdout, stderr = await asyncio.wait_for(
323
- r_proc.communicate(), timeout=120
324
- )
325
- if image_path:
326
- os.unlink(image_path)
327
-
328
- output = stdout.decode("utf-8", errors="replace").strip()
329
- # Try to parse as JSON (llama-mtmd-cli may output raw text)
330
- try:
331
- result = json_mod.loads(output)
332
- return JSONResponse(result)
333
- except json_mod.JSONDecodeError:
334
- return JSONResponse({"response": output, "raw": True})
335
-
336
- except asyncio.TimeoutError:
337
- if image_path and os.path.exists(image_path):
338
- os.unlink(image_path)
339
- return JSONResponse(
340
- {"error": "Request timed out"}, status_code=504
341
- )
342
- except Exception as e:
343
- if image_path and os.path.exists(image_path):
344
- os.unlink(image_path)
345
- return JSONResponse({"error": str(e)}, status_code=500)
346
 
347
  @web_app.get("/")
348
  async def root():
349
- return {"service": "PregoPal MiniCPM-o-4_5 API", "version": "1.0.0"}
 
 
 
 
 
 
 
 
 
350
 
351
  return web_app
352
 
353
 
354
- # ── Deploy helpers ─────────────────────────────────────────
 
 
355
 
356
  @app.function(
357
- image=llamacpp_image,
358
  volumes={MODEL_DIR: model_volume},
359
  gpu="A100",
360
  timeout=3600,
@@ -365,22 +275,22 @@ def upload_models():
365
  Run: modal run modal_deploy/deploy.py::upload_models
366
  Requires models in ../models/MiniCPM-o-4_5-gguf/
367
  """
368
- import shutil
369
- local_dir = "/local_models"
370
-
371
- # This function assumes models are mounted locally
372
- # On Modal, you'd use modal volume put instead
373
- print("Model upload placeholder - use 'modal volume put' CLI")
374
- print(f" modal volume put minicpm-o-4_5-models ../models/MiniCPM-o-4_5-gguf /")
375
 
376
 
377
- # ── Health check function ───────────────────────────────────
 
 
378
 
379
  @app.function(
380
- image=llamacpp_image,
381
  volumes={MODEL_DIR: model_volume},
382
  gpu="A100",
383
- timeout=300,
384
  )
385
  def test_inference():
386
  """Quick test to verify model loads and runs.
@@ -392,26 +302,29 @@ def test_inference():
392
 
393
  port = 8081
394
  args = build_server_args(MODEL_SUBDIR, port)
395
-
396
  print(f"Starting server: {' '.join(args)}")
397
  proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
398
-
399
  url = f"http://127.0.0.1:{port}"
400
- deadline = time.time() + 120
401
  while time.time() < deadline:
402
  try:
403
  r = httpx.get(f"{url}/health", timeout=5)
404
  if r.status_code == 200:
405
  print("Server healthy!")
406
  break
407
- except Exception:
408
  pass
409
  time.sleep(2)
410
  else:
 
 
 
411
  proc.terminate()
412
  raise RuntimeError("Server failed to start")
413
 
414
- # Test completion
415
  r = httpx.post(
416
  f"{url}/v1/chat/completions",
417
  json={
@@ -424,5 +337,12 @@ def test_inference():
424
  },
425
  timeout=120,
426
  )
427
- print(f"Response: {r.json()}")
428
- proc.terminate()
 
 
 
 
 
 
 
 
1
  """
2
  Modal deployment for MiniCPM-o-4_5 via llama.cpp server
3
+ Serves OpenAI-compatible chat API + multimodal (vision) endpoints
4
+
5
+ Usage:
6
+ # Deploy to Modal (builds image, uploads code, starts app)
7
+ modal deploy modal_deploy.deploy
8
+
9
+ # Test inference on Modal GPU
10
+ modal run modal_deploy.deploy::test_inference
11
+
12
+ Architecture:
13
+ User Request → Modal ASGI (FastAPI) → llama-server (OpenAI-compatible)
14
+
15
+ Modal Volume (GGUF model persistent storage)
16
  """
17
  import os
18
  import sys
19
  import subprocess
20
  import modal
21
+ from modal import Image, App, Volume, asgi_app
22
 
23
+ # ═══════════════════════════════════════════════════════════════
24
+ # 1. BASE IMAGE: llama.cpp compiled once, cached by Modal
25
+ # ═══════════════════════════════════════════════════════════════
 
 
 
 
26
 
27
+ _llamacpp_image = (
 
28
  Image.debian_slim(python_version="3.11")
29
+ .apt_install(
30
+ "curl", "git", "build-essential", "cmake",
31
+ "libcurl4-openssl-dev", "software-properties-common", "wget"
32
+ )
33
  .run_commands(
34
+ # Install CUDA toolkit for GPU inference on A100
35
  "wget -q https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64/cuda-keyring_1.1-1_all.deb",
36
  "dpkg -i cuda-keyring_1.1-1_all.deb",
37
  "apt-get update -qq",
38
  "apt-get install -y -qq cuda-toolkit-12-1 cuda-compiler-12-1 2>&1 | tail -5",
 
 
 
39
  )
 
40
  .env({"CUDACXX": "/usr/local/cuda/bin/nvcc",
41
  "CUDA_HOME": "/usr/local/cuda-12.1",
42
  "LD_LIBRARY_PATH": "/usr/local/cuda-12.1/lib64"})
43
+ .run_commands(
44
+ "echo '/usr/local/cuda-12.1/lib64' >> /etc/ld.so.conf.d/cuda.conf",
45
+ "ldconfig",
46
+ )
47
+ .pip_install("fastapi", "uvicorn", "httpx", "numpy", "Pillow", "soundfile")
48
  .run_commands(
49
  "git clone --depth 1 https://github.com/ggerganov/llama.cpp /llama.cpp",
50
+ # Build with CUDA - single job to prevent OOM on build server
51
+ # NOTE: -DCMAKE_CUDA_ARCHITECTURES=80-real = A100 only (faster build)
52
+ "cd /llama.cpp && cmake -B build -DBUILD_SHARED_LIBS=OFF -DCMAKE_BUILD_TYPE=Release -DGGML_CUDA=ON -DLLAMA_CURL=ON -DLLAMA_BUILD_SERVER=ON -DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF -DCMAKE_CUDA_ARCHITECTURES=80-real",
53
+ # Use -j1 to prevent OOM (each nvcc process ~1.5-2GB, build server ~8GB RAM)
54
+ "cd /llama.cpp && cmake --build build --config Release -j1 --target llama-server llama-mtmd-cli",
55
  )
56
  )
57
 
58
+ # ═══════════════════════════════════════════════════════════════
59
+ # 2. CONSTANTS
60
+ # ═══════════════════════════════════════════════════════════════
61
+
62
+ MODEL_DIR = "/models"
63
+ MODEL_SUBDIR = f"{MODEL_DIR}/MiniCPM-o-4_5-gguf"
64
+ MAIN_GGUF = "MiniCPM-o-4_5-Q4_K_M.gguf"
65
+ VISION_MMPROJ = f"vision/MiniCPM-o-4_5-vision-F16.gguf"
66
+ AUDIO_MMPROJ = f"audio/MiniCPM-o-4_5-audio-F16.gguf"
67
+
68
  # Volume to store models (persisted across restarts)
69
  model_volume = Volume.from_name("minicpm-o-4_5-models", create_if_missing=True)
70
 
71
+ # Application
72
  app = App("prego-pal-minicpm")
73
 
74
+ # ═══════════════════════════════════════════════════════════════
75
+ # 3. BUILD SERVER ARGS
76
+ # ═══════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
  def build_server_args(model_dir: str, port: int = 8080) -> list[str]:
79
  """Construct llama-server command line arguments.
80
 
81
+ CRITICAL: --mmproj passes a *single* multimodal projection file to
82
+ enable vision support via /v1/chat/completions with image_url content.
83
+
84
+ Do NOT pass audio/tts/token2wav .gguf files here - those are loaded
85
+ separately by llama-mtmd-cli for speech tasks only.
86
  """
87
+ main_model_path = os.path.join(model_dir, MAIN_GGUF)
88
+ vision_path = os.path.join(model_dir, VISION_MMPROJ)
89
+
90
  args = [
91
  "/llama.cpp/build/bin/llama-server",
92
  "-m", main_model_path,
93
  "--host", "0.0.0.0",
94
  "--port", str(port),
95
+ "-ngl", "99", # offload all layers to GPU
96
+ "-c", "8192", # context size
97
+ "--no-mmap", # compatibility with Modal tmpfs
98
  ]
99
 
100
+ # Attach vision mmproj for multimodal support (image understanding)
101
+ if os.path.isfile(vision_path):
102
+ args.extend(["--mmproj", vision_path])
103
+ print(f"[PregoPal] Vision mmproj: {vision_path}")
104
+ else:
105
+ print(f"[PregoPal] WARNING: Vision mmproj not found at {vision_path}")
106
+ print(f"[PregoPal] Available files in {model_dir}:")
107
+ for root, dirs, files in os.walk(model_dir):
108
+ for f in files:
109
+ if f.endswith(".gguf"):
110
+ print(f" {os.path.join(root, f)}")
111
 
112
  return args
113
 
114
 
115
+ # ═══════════════════════════════════════════════════════════════
116
+ # 4. MAIN ASGI ENTRYPOINT
117
+ # ═══════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
118
 
119
  @app.function(
120
+ image=_llamacpp_image,
121
  volumes={MODEL_DIR: model_volume},
122
  scaledown_window=300, # shutdown after 5min idle
123
+ gpu="A100", # MiniCPM-o-4_5 needs A100 for 12GB model
124
+ timeout=1200, # 20 min timeout for cold start model loading
125
  )
126
+ @modal.concurrent(max_inputs=10)
127
  @asgi_app()
128
  def serve():
129
+ """FastAPI app wrapping llama-server for OpenAI-compatible endpoints."""
 
 
 
 
130
  import asyncio
131
  import httpx
132
  from fastapi import FastAPI, Request
 
157
 
158
  llama_url = f"http://127.0.0.1:{server_port}"
159
 
160
+ # Wait for server to be ready (long timeout for cold start)
161
+ async def wait_for_server(timeout: float = 900.0):
162
+ async with httpx.AsyncClient(timeout=30) as client:
163
  start = asyncio.get_event_loop().time()
164
  while True:
165
  try:
166
+ r = await client.get(f"{llama_url}/health", timeout=10)
167
  if r.status_code == 200:
168
  print("[PregoPal] llama-server ready!")
169
  return
170
+ except Exception as e:
171
+ print(f"[PregoPal] Waiting for server... ({type(e).__name__})")
172
+ elapsed = asyncio.get_event_loop().time() - start
173
+ if elapsed > timeout:
174
+ # Dump server logs on timeout to diagnose
175
+ print("[PregoPal] TIMEOUT! Dumping server output:")
176
+ if proc.stdout:
177
+ try:
178
+ output = proc.stdout.read(2048)
179
+ print(output[-2048:])
180
+ except Exception:
181
+ pass
182
+ raise RuntimeError(f"llama-server startup timed out after {timeout}s")
183
+ await asyncio.sleep(5)
184
 
185
  @web_app.on_event("startup")
186
  async def startup():
 
195
  except subprocess.TimeoutExpired:
196
  proc.kill()
197
 
198
+ # ═══════════════════════════════════════════════════
199
+ # 4a. PROXY ENDPOINTS
200
+ # ═══════════════════════════════════════════════════
201
 
202
  @web_app.post("/v1/chat/completions")
203
  async def chat_completions(request: Request):
204
+ """OpenAI-compatible chat completions.
205
+
206
+ llama-server >= b4690 supports multimodal natively via /v1/chat/completions
207
+ with content parts including image_url (base64).
208
+
209
+ This proxy passes requests directly through to llama-server.
210
+ """
211
  body = await request.json()
212
  async with httpx.AsyncClient(timeout=300) as client:
213
  r = await client.post(f"{llama_url}/v1/chat/completions", json=body)
 
214
  if body.get("stream", False):
215
  return StreamingResponse(
216
  r.aiter_bytes(),
 
239
  r = await client.get(f"{llama_url}/v1/models")
240
  return JSONResponse(r.json(), status_code=r.status_code)
241
 
242
+ # ═══════════════════════════════════════════════════
243
+ # 4b. HEALTH & INFO
244
+ # ═══════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
 
246
  @web_app.get("/")
247
  async def root():
248
+ return {
249
+ "service": "PregoPal MiniCPM-o-4_5 API",
250
+ "version": "1.0.0",
251
+ "endpoints": {
252
+ "chat": "/v1/chat/completions (POST) - OpenAI-compatible, supports text + image",
253
+ "embeddings": "/v1/embeddings (POST)",
254
+ "models": "/v1/models (GET)",
255
+ "health": "/health (GET)",
256
+ }
257
+ }
258
 
259
  return web_app
260
 
261
 
262
+ # ═══════════════════════════════════════════════════════════════
263
+ # 5. DEPLOY HELPERS
264
+ # ═══════════════════════════════════════════════════════════════
265
 
266
  @app.function(
267
+ image=_llamacpp_image,
268
  volumes={MODEL_DIR: model_volume},
269
  gpu="A100",
270
  timeout=3600,
 
275
  Run: modal run modal_deploy/deploy.py::upload_models
276
  Requires models in ../models/MiniCPM-o-4_5-gguf/
277
  """
278
+ print("Upload models using CLI:")
279
+ print(" modal volume put minicpm-o-4_5-models ../models/MiniCPM-o-4_5-gguf /")
280
+ print()
281
+ print("Verify with:")
282
+ print(" modal volume ls minicpm-o-4_5-models /")
 
 
283
 
284
 
285
+ # ═══════════════════════════════════════════════════════════════
286
+ # 6. TEST INFERENCE (runs on Modal GPU)
287
+ # ═══════════════════════════════════════════════════════════════
288
 
289
  @app.function(
290
+ image=_llamacpp_image,
291
  volumes={MODEL_DIR: model_volume},
292
  gpu="A100",
293
+ timeout=600,
294
  )
295
  def test_inference():
296
  """Quick test to verify model loads and runs.
 
302
 
303
  port = 8081
304
  args = build_server_args(MODEL_SUBDIR, port)
305
+
306
  print(f"Starting server: {' '.join(args)}")
307
  proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
308
+
309
  url = f"http://127.0.0.1:{port}"
310
+ deadline = time.time() + 180 # 3 min for model load
311
  while time.time() < deadline:
312
  try:
313
  r = httpx.get(f"{url}/health", timeout=5)
314
  if r.status_code == 200:
315
  print("Server healthy!")
316
  break
317
+ except Exception as e:
318
  pass
319
  time.sleep(2)
320
  else:
321
+ # Dump server output
322
+ if proc.stdout:
323
+ print("Server output:", proc.stdout.read(1024))
324
  proc.terminate()
325
  raise RuntimeError("Server failed to start")
326
 
327
+ # Test text completion
328
  r = httpx.post(
329
  f"{url}/v1/chat/completions",
330
  json={
 
337
  },
338
  timeout=120,
339
  )
340
+ print(f"Text response: {r.json()}")
341
+
342
+ # Test multimodal if vision mmproj exists
343
+ vision_path = os.path.join(MODEL_SUBDIR, VISION_MMPROJ)
344
+ if os.path.isfile(vision_path):
345
+ print("Vision mmproj found, skipping multimodal test (no test image available)")
346
+
347
+ proc.terminate()
348
+ print("\n✅ Test passed!")