J.B-Lin commited on
Commit
fb89443
·
1 Parent(s): 4a3e24c

重构modal部署使用CUDA编译, streaming修复, cookbook_ref gitignored

Browse files
Files changed (2) hide show
  1. .gitignore +1 -0
  2. modal_deploy/deploy.py +219 -235
.gitignore CHANGED
@@ -38,3 +38,4 @@ debug*.txt
38
  # 检查脚本(一次性使用)
39
  _check_hf.py
40
  _download_models.py
 
 
38
  # 检查脚本(一次性使用)
39
  _check_hf.py
40
  _download_models.py
41
+ "cookbook_ref/"
modal_deploy/deploy.py CHANGED
@@ -1,57 +1,58 @@
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
 
@@ -62,79 +63,52 @@ _llamacpp_image = (
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
133
  from fastapi.responses import StreamingResponse, JSONResponse
134
  from fastapi.middleware.cors import CORSMiddleware
 
135
 
136
- web_app = FastAPI(title="PregoPal MiniCPM-o API")
 
137
 
 
138
  web_app.add_middleware(
139
  CORSMiddleware,
140
  allow_origins=["*"],
@@ -143,113 +117,115 @@ def serve():
143
  allow_headers=["*"],
144
  )
145
 
146
- # Start llama-server in background
147
- server_port = 8080
148
- server_args = build_server_args(MODEL_SUBDIR, server_port)
149
-
150
- print(f"[PregoPal] Starting llama-server: {' '.join(server_args)}")
151
- proc = subprocess.Popen(
152
- server_args,
153
- stdout=subprocess.PIPE,
154
- stderr=subprocess.STDOUT,
155
- text=True,
 
156
  )
 
 
 
 
 
 
 
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():
187
- # Wait longer for cold start (loading 12GB model over Volume mount)
188
- await wait_for_server()
189
-
190
- @web_app.on_event("shutdown")
191
- async def shutdown():
192
- proc.terminate()
193
- try:
194
- proc.wait(timeout=10)
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(),
217
- media_type="text/event-stream",
218
- headers=dict(r.headers),
219
- )
220
- return JSONResponse(r.json(), status_code=r.status_code)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
 
222
  @web_app.post("/v1/embeddings")
223
  async def embeddings(request: Request):
224
- """OpenAI-compatible embeddings."""
225
  body = await request.json()
226
- async with httpx.AsyncClient(timeout=120) as client:
227
- r = await client.post(f"{llama_url}/v1/embeddings", json=body)
228
- return JSONResponse(r.json(), status_code=r.status_code)
 
 
229
 
230
  @web_app.get("/health")
231
  async def health():
232
- async with httpx.AsyncClient() as client:
233
- r = await client.get(f"{llama_url}/health")
234
- return JSONResponse(r.json(), status_code=r.status_code)
 
 
 
235
 
236
  @web_app.get("/v1/models")
237
  async def list_models():
238
- async with httpx.AsyncClient() as client:
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)",
@@ -258,91 +234,99 @@ def serve():
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,
271
  )
272
  def upload_models():
273
- """One-time function to upload model files to Modal Volume.
274
-
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.
297
-
298
- Run: modal run modal_deploy/deploy.py::test_inference
299
- """
300
- import httpx
301
  import time
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={
331
- "model": "MiniCPM-o-4_5",
332
- "messages": [
333
- {"role": "user", "content": "Say hello in Chinese"}
334
- ],
335
- "max_tokens": 50,
336
- "temperature": 0.1,
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!")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ Modal deployment for MiniCPM-o-4_5 via llama-cpp-python (CUDA).
3
+ Compiles with GGML_CUDA=ON during image build (~5min, within Modal's 15min limit).
4
 
5
  Usage:
6
+ modal deploy modal_deploy.deploy # Deploy (~5min build)
7
+ modal run modal_deploy.deploy::test_inference # Test
 
 
 
8
 
9
  Architecture:
10
+ User → FastAPI (ASGI, OpenAI-compatible) → llama-cpp-python (CUDA)
11
+
12
+ Modal Volume (GGUF models)
13
  """
14
  import os
 
 
15
  import modal
16
  from modal import Image, App, Volume, asgi_app
17
 
18
  # ═══════════════════════════════════════════════════════════════
19
+ # 1. BASE IMAGE
20
+ # ═══════════════════════════════════════════════════════════════
21
+ # Compile llama-cpp-python with CUDA support at image build time.
22
+ # Modal provides 15min build timeout — sufficient for CUDA compilation.
23
  # ═══════════════════════════════════════════════════════════════
24
 
25
+ _image = (
26
  Image.debian_slim(python_version="3.11")
27
+ .apt_install("build-essential", "cmake", "curl", "wget", "git",
28
+ "libcurl4-openssl-dev")
29
+ # Install CUDA toolkit (needed for llama.cpp CUDA compilation)
 
30
  .run_commands(
 
31
  "wget -q https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64/cuda-keyring_1.1-1_all.deb",
32
  "dpkg -i cuda-keyring_1.1-1_all.deb",
33
  "apt-get update -qq",
34
+ "apt-get install -y -qq cuda-compiler-12-1 cuda-cudart-dev-12-1 2>&1 | tail -3",
35
  )
36
+ .env({
37
+ "CUDA_HOME": "/usr/local/cuda-12.1",
38
+ "PATH": "/usr/local/cuda-12.1/bin:${PATH}",
39
+ "LD_LIBRARY_PATH": "/usr/local/cuda-12.1/lib64:${LD_LIBRARY_PATH}",
40
+ "CMAKE_ARGS": "-DGGML_CUDA=ON -DGGML_CUDA_ARCHS=sm_80",
41
+ "FORCE_CMAKE": "1",
42
+ })
43
+ .pip_install(
44
+ "fastapi",
45
+ "uvicorn[standard]",
46
+ "httpx",
47
+ "numpy",
48
+ "Pillow",
49
+ # Install llama-cpp-python with CUDA (compiles at build time)
50
+ "llama-cpp-python",
51
+ extra_args=["--force-reinstall", "--no-cache-dir"],
52
  )
 
53
  .run_commands(
54
+ # Verify CUDA is available
55
+ "python -c 'from llama_cpp import Llama; print(f\"CUDA available: {Llama.supports_gpu()}\")'",
 
 
 
 
56
  )
57
  )
58
 
 
63
  MODEL_DIR = "/models"
64
  MODEL_SUBDIR = f"{MODEL_DIR}/MiniCPM-o-4_5-gguf"
65
  MAIN_GGUF = "MiniCPM-o-4_5-Q4_K_M.gguf"
66
+ VISION_MMPROJ = "vision/MiniCPM-o-4_5-vision-F16.gguf"
 
67
 
 
68
  model_volume = Volume.from_name("minicpm-o-4_5-models", create_if_missing=True)
 
 
69
  app = App("prego-pal-minicpm")
70
 
71
  # ═══════════════════════════════════════════════════════════════
72
+ # 3. MODEL HELPER
73
  # ═══════════════════════════════════════════════════════════════
74
 
75
+ def get_model_paths(base_dir: str) -> dict:
76
+ """Return validated model paths."""
77
+ main_path = os.path.join(base_dir, MAIN_GGUF)
78
+ vision_path = os.path.join(base_dir, VISION_MMPROJ)
79
+ paths = {"main": main_path, "vision": vision_path}
80
+ for key, path in paths.items():
81
+ print(f"[PregoPal] {key}: {path} (exists={os.path.isfile(path)})")
82
+ return paths
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
  # ═══════════════════════════════════════════════════════════════
85
+ # 4. ASGI APP (OpenAI-compatible API)
86
  # ═══════════════════════════════════════════════════════════════
87
 
88
  @app.function(
89
+ image=_image,
90
  volumes={MODEL_DIR: model_volume},
91
+ scaledown_window=300, # Keep warm 5min after last request
92
+ gpu="A100", # Use A100 GPU via Modal
93
+ timeout=1200, # Max function execution time
94
+ allow_concurrent_inputs=10, # Up to 10 parallel requests
95
+ container_idle_timeout=60, # Keep container 60s between requests
96
  )
 
97
  @asgi_app()
98
  def serve():
 
99
  import asyncio
100
+ import json
101
+ import logging
102
+ from pathlib import Path
103
  from fastapi import FastAPI, Request
104
  from fastapi.responses import StreamingResponse, JSONResponse
105
  from fastapi.middleware.cors import CORSMiddleware
106
+ from llama_cpp import Llama
107
 
108
+ logging.basicConfig(level=logging.INFO)
109
+ logger = logging.getLogger("prego-pal")
110
 
111
+ web_app = FastAPI(title="PregoPal MiniCPM-o-4_5 API")
112
  web_app.add_middleware(
113
  CORSMiddleware,
114
  allow_origins=["*"],
 
117
  allow_headers=["*"],
118
  )
119
 
120
+ # ── Model Loading ──
121
+ paths = get_model_paths(MODEL_SUBDIR)
122
+ model_path = paths["main"]
123
+ vision_path = paths["vision"]
124
+
125
+ kwargs: dict = dict(
126
+ model_path=model_path,
127
+ n_gpu_layers=-1, # Offload ALL layers to GPU
128
+ n_ctx=8192, # Context window
129
+ verbose=False, # Keep logs clean
130
+ n_threads=os.cpu_count() or 4,
131
  )
132
+ if os.path.isfile(vision_path):
133
+ kwargs["mmproj"] = vision_path
134
+ logger.info("[PregoPal] Vision mmproj enabled")
135
+
136
+ logger.info("[PregoPal] Loading model (this may take 30-90s)...")
137
+ llm = Llama(**kwargs)
138
+ logger.info("[PregoPal] Model loaded! Ready for inference.")
139
 
140
+ # ── Endpoints ──
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
  @web_app.post("/v1/chat/completions")
143
  async def chat_completions(request: Request):
 
 
 
 
 
 
 
144
  body = await request.json()
145
+ stream = body.get("stream", False)
146
+ messages = body.get("messages", [])
147
+ max_tokens = body.get("max_tokens", 512)
148
+ temperature = body.get("temperature", 0.7)
149
+ top_p = body.get("top_p", 0.9)
150
+ model = body.get("model", "MiniCPM-o-4_5")
151
+
152
+ if stream:
153
+ async def event_stream():
154
+ for chunk in llm.create_chat_completion(
155
+ messages=messages,
156
+ max_tokens=max_tokens,
157
+ temperature=temperature,
158
+ top_p=top_p,
159
+ stream=True,
160
+ ):
161
+ yield f"data: {json.dumps(chunk)}\n\n"
162
+ yield "data: [DONE]\n\n"
163
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
164
+
165
+ result = llm.create_chat_completion(
166
+ messages=messages,
167
+ max_tokens=max_tokens,
168
+ temperature=temperature,
169
+ top_p=top_p,
170
+ stream=False,
171
+ )
172
+ return JSONResponse(result)
173
+
174
+ @web_app.post("/v1/completions")
175
+ async def completions(request: Request):
176
+ body = await request.json()
177
+ prompt = body.get("prompt", "")
178
+ max_tokens = body.get("max_tokens", 256)
179
+ temperature = body.get("temperature", 0.7)
180
+
181
+ result = llm.create_completion(
182
+ prompt=prompt,
183
+ max_tokens=max_tokens,
184
+ temperature=temperature,
185
+ stream=False,
186
+ )
187
+ return JSONResponse(result)
188
 
189
  @web_app.post("/v1/embeddings")
190
  async def embeddings(request: Request):
 
191
  body = await request.json()
192
+ result = llm.create_embedding(
193
+ input=body.get("input", ""),
194
+ model=body.get("model", "MiniCPM-o-4_5"),
195
+ )
196
+ return JSONResponse(result)
197
 
198
  @web_app.get("/health")
199
  async def health():
200
+ # Quick check: model loaded
201
+ return {
202
+ "status": "ok",
203
+ "model": "MiniCPM-o-4_5",
204
+ "cuda": True,
205
+ }
206
 
207
  @web_app.get("/v1/models")
208
  async def list_models():
209
+ return {
210
+ "object": "list",
211
+ "data": [{
212
+ "id": "MiniCPM-o-4_5",
213
+ "object": "model",
214
+ "created": 1,
215
+ "owned_by": "prego-pal",
216
+ "permission": [],
217
+ }],
218
+ }
219
 
220
  @web_app.get("/")
221
  async def root():
222
  return {
223
+ "service": "PregoPal MiniCPM-o-4_5 API (CUDA)",
224
+ "version": "1.2.0",
225
+ "model": "MiniCPM-o-4_5-Q4_K_M",
226
  "endpoints": {
227
+ "chat": "/v1/chat/completions (POST, streaming+non-streaming)",
228
+ "completions": "/v1/completions (POST)",
229
  "embeddings": "/v1/embeddings (POST)",
230
  "models": "/v1/models (GET)",
231
  "health": "/health (GET)",
 
234
 
235
  return web_app
236
 
 
237
  # ═══════════════════════════════════════════════════════════════
238
+ # 5. MODEL UPLOAD HELPER
239
  # ═══════════════════════════════════════════════════════════════
240
 
241
  @app.function(
242
+ image=_image,
243
  volumes={MODEL_DIR: model_volume},
 
244
  timeout=3600,
245
  )
246
  def upload_models():
247
+ """Print instructions for uploading models to Modal Volume."""
248
+ print("=" * 60)
249
+ print("Upload model files to Modal Volume:")
250
+ print()
251
+ print(" # From your local models directory:")
252
+ print(" modal volume put minicpm-o-4_5-models \\")
253
+ print(" ../models/MiniCPM-o-4_5-gguf /")
254
  print()
255
+ print(" # Verify:")
256
  print(" modal volume ls minicpm-o-4_5-models /")
257
+ print("=" * 60)
258
 
259
  # ═══════════════════════════════════════════════════════════════
260
+ # 6. TEST INFERENCE (One-shot, not as ASGI)
261
  # ═══════════════════════════════════════════════════════════════
262
 
263
  @app.function(
264
+ image=_image,
265
  volumes={MODEL_DIR: model_volume},
266
  gpu="A100",
267
  timeout=600,
268
  )
269
  def test_inference():
270
+ """Test inference on Modal (downloads model from Volume)."""
 
 
 
 
271
  import time
272
+ import json
273
+ from llama_cpp import Llama
274
+
275
+ print("[PregoPal] ========== TEST INFERENCE ==========")
276
+ print(f"[PregoPal] Timestamp: {time.strftime('%Y-%m-%d %H:%M:%S')}")
277
+ print(f"[PregoPal] GPU: A100 (via Modal)")
278
+ print(f"[PregoPal] Model base: {MODEL_SUBDIR}")
279
+
280
+ paths = get_model_paths(MODEL_SUBDIR)
281
+ main_path = paths["main"]
282
+ vision_path = paths["vision"]
283
+
284
+ if not os.path.isfile(main_path):
285
+ print(f"[PregoPal] ERROR: Model not found at {main_path}")
286
+ print("[PregoPal] Upload models first: modal run modal_deploy.deploy::upload_models")
287
+ return
288
+
289
+ # Load model
290
+ t0 = time.time()
291
+ kwargs = dict(
292
+ model_path=main_path,
293
+ n_gpu_layers=-1,
294
+ n_ctx=4096,
295
+ verbose=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  )
 
 
 
 
297
  if os.path.isfile(vision_path):
298
+ kwargs["mmproj"] = vision_path
299
+
300
+ print("[PregoPal] Loading model...")
301
+ llm = Llama(**kwargs)
302
+ load_time = time.time() - t0
303
+ print(f"[PregoPal] Model loaded in {load_time:.1f}s")
304
+
305
+ # ── Test 1: Text generation ──
306
+ print("\n[Test 1] Chinese greeting...")
307
+ t0 = time.time()
308
+ result = llm.create_chat_completion(
309
+ messages=[{"role": "user", "content": "用中文说你好,不超过10个字"}],
310
+ max_tokens=30,
311
+ temperature=0.1,
312
+ )
313
+ elapsed = time.time() - t0
314
+ content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
315
+ print(f"Response ({elapsed:.1f}s): {content}")
316
+
317
+ # ── Test 2: English text ──
318
+ print("\n[Test 2] English instruction...")
319
+ t0 = time.time()
320
+ result = llm.create_chat_completion(
321
+ messages=[{"role": "user", "content": "What is the capital of France? Answer in 5 words."}],
322
+ max_tokens=30,
323
+ temperature=0.1,
324
+ )
325
+ elapsed = time.time() - t0
326
+ content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
327
+ print(f"Response ({elapsed:.1f}s): {content}")
328
+
329
+ print(f"\n{'='*50}")
330
+ print(f"✅ Test complete! Loading: {load_time:.1f}s")
331
+ print(f"✅ Inference speed: {elapsed:.1f}s per response (CUDA)")
332
+ print(f"{'='*50}")