J.B-Lin commited on
Commit
96ae0a2
·
2 Parent(s): f54083c3f78a89

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

Browse files
modal_deploy/deploy_omni.py ADDED
@@ -0,0 +1,558 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PregoPal x MiniCPM-o-4_5 - Modal deploy (llama.cpp-omni full-duplex voice upgrade)
3
+
4
+ Architecture:
5
+ FastAPI (ASGI) <-> llama-server (OpenBMB/llama.cpp-omni subprocess)
6
+ |
7
+ Modal Volume: GGUF models (vision + audio + TTS)
8
+
9
+ Usage:
10
+ pip install modal
11
+ modal token new
12
+ modal deploy modal_deploy.deploy_omni
13
+
14
+ Test:
15
+ modal run -m modal_deploy.deploy_omni::test_inference
16
+ modal run -m modal_deploy.deploy_omni::diagnose_volume
17
+
18
+ API:
19
+ POST /v1/chat/completions - OpenAI compatible (text + multimodal, streaming)
20
+ POST /v1/audio/speech - TTS: text -> voice WAV
21
+ POST /v1/audio/transcriptions - STT: voice -> text
22
+ POST /v1/embeddings - Embeddings
23
+ GET /health - Health check (audio/vision/TTS status)
24
+ GET /v1/models - Model list
25
+ """
26
+
27
+ import os
28
+ import modal
29
+ from modal import Image, App, Volume, asgi_app
30
+
31
+ # ============================================================================
32
+ # 1. IMAGE - Build OpenBMB/llama.cpp-omni from source
33
+ # Source is copied from local llamacpp_omni/ (repo no longer public on GitHub)
34
+ # ============================================================================
35
+
36
+ _omni_image = (
37
+ Image.debian_slim(python_version="3.11")
38
+ .apt_install(
39
+ "curl",
40
+ )
41
+ # Install CUDA Toolkit for compiling llama.cpp CUDA kernels
42
+ .run_commands(
43
+ "curl -L -o /tmp/cuda-keyring.deb https://developer.download.nvidia.com/compute/cuda/repos/debian12/x86_64/cuda-keyring_1.1-1_all.deb",
44
+ "dpkg -i /tmp/cuda-keyring.deb",
45
+ "apt-get update",
46
+ "apt-get install -y cuda-toolkit-12-4 cuda-compiler-12-4",
47
+ )
48
+ .apt_install(
49
+ "curl",
50
+ "git",
51
+ "build-essential",
52
+ "cmake",
53
+ "libcurl4-openssl-dev",
54
+ "libsndfile1",
55
+ "libasound2-dev",
56
+ "pkg-config",
57
+ )
58
+ .pip_install(
59
+ "fastapi",
60
+ "uvicorn[standard]",
61
+ "httpx",
62
+ "numpy",
63
+ "Pillow",
64
+ "soundfile",
65
+ )
66
+ # Copy local llamacpp_omni source into image (repo no longer public)
67
+ .add_local_dir(
68
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "llamacpp_omni"),
69
+ "/llama.cpp-omni",
70
+ copy=True,
71
+ )
72
+ .run_commands(
73
+ "cd /llama.cpp-omni && cmake -B build "
74
+ "-DGGML_CUDA=ON "
75
+ "-DLLAMA_CURL=ON "
76
+ "-DLLAMA_BUILD_SERVER=ON "
77
+ "-DLLAMA_BUILD_TESTS=OFF "
78
+ "-DLLAMA_BUILD_EXAMPLES=OFF "
79
+ "-DLLAMA_CUDA_FORCE_MMQ=ON "
80
+ "-DCMAKE_CUDA_ARCHITECTURES='75;89' "
81
+ "-DCMAKE_BUILD_TYPE=Release "
82
+ "-DCMAKE_CUDA_COMPILER=/usr/local/cuda-12/bin/nvcc",
83
+ "cd /llama.cpp-omni && cmake --build build --config Release -j $(nproc) "
84
+ "--target llama-server",
85
+ "ls -lh /llama.cpp-omni/build/bin/llama-server",
86
+ )
87
+ )
88
+
89
+ # ============================================================================
90
+ # 2. CONSTANTS
91
+ # ============================================================================
92
+
93
+ MODEL_DIR = "/models"
94
+ MODEL_SUBDIR = f"{MODEL_DIR}/MiniCPM-o-4_5-gguf"
95
+
96
+ MAIN_GGUF = "MiniCPM-o-4_5-Q4_K_M.gguf"
97
+ VISION_MMPROJ = "vision/MiniCPM-o-4_5-vision-F16.gguf"
98
+ AUDIO_MMPROJ = "audio/MiniCPM-o-4_5-audio-F16.gguf"
99
+ TTS_BASE_LM = "tts/MiniCPM-o-4_5-tts-F16.gguf"
100
+ TTS_ACOUSTIC = "tts/MiniCPM-o-4_5-projector-F16.gguf"
101
+ TOKEN2WAV_DIR = "token2wav-gguf"
102
+
103
+ LLAMA_SERVER_PORT = 8081
104
+
105
+ model_volume = Volume.from_name("minicpm-o-4_5-models", create_if_missing=True)
106
+ app = App("prego-pal-minicpm-omni")
107
+
108
+
109
+ def get_model_paths(base_dir: str) -> dict:
110
+ paths = {
111
+ "main": os.path.join(base_dir, MAIN_GGUF),
112
+ "vision": os.path.join(base_dir, VISION_MMPROJ),
113
+ "audio": os.path.join(base_dir, AUDIO_MMPROJ),
114
+ "tts_base_lm": os.path.join(base_dir, TTS_BASE_LM),
115
+ "tts_acoustic": os.path.join(base_dir, TTS_ACOUSTIC),
116
+ "token2wav_dir": os.path.join(base_dir, TOKEN2WAV_DIR),
117
+ }
118
+ for key, path in paths.items():
119
+ if key == "token2wav_dir":
120
+ exists = os.path.isdir(path)
121
+ else:
122
+ exists = os.path.isfile(path)
123
+ print(f"[PregoPal] {key}: {path} (exists={exists})")
124
+ return paths
125
+
126
+
127
+ # ============================================================================
128
+ # 3. ASGI APP - FastAPI lifespan + llama-server subprocess
129
+ # ============================================================================
130
+
131
+ @app.function(
132
+ image=_omni_image,
133
+ volumes={MODEL_DIR: model_volume},
134
+ gpu="T4",
135
+ timeout=1200,
136
+ scaledown_window=300,
137
+ )
138
+ @modal.concurrent(max_inputs=10)
139
+ @asgi_app()
140
+ def serve():
141
+ """
142
+ FastAPI ASGI app. Launches llama-server subprocess in lifespan.
143
+ serve() is sync; async logic lives in lifespan context manager.
144
+ """
145
+ import asyncio
146
+ import json
147
+ import logging
148
+ import subprocess
149
+ from contextlib import asynccontextmanager
150
+ from fastapi import FastAPI, Request
151
+ from fastapi.responses import StreamingResponse, JSONResponse
152
+ from fastapi.middleware.cors import CORSMiddleware
153
+ import httpx
154
+
155
+ logging.basicConfig(level=logging.INFO)
156
+ logger = logging.getLogger("prego-pal-omni")
157
+
158
+ paths = get_model_paths(MODEL_SUBDIR)
159
+
160
+ # Build llama-server command
161
+ llama_server_bin = "/llama.cpp-omni/build/bin/llama-server"
162
+ if not os.path.isfile(llama_server_bin):
163
+ llama_server_bin = "/llama.cpp-omni/build/bin/Release/llama-server"
164
+
165
+ cmd = [
166
+ llama_server_bin,
167
+ "-m", paths["main"],
168
+ "--mmproj", paths["vision"],
169
+ "--mmproj", paths["audio"],
170
+ "--voxcpm2-base-lm", paths["tts_base_lm"],
171
+ "--voxcpm2-acoustic", paths["tts_acoustic"],
172
+ "--host", "127.0.0.1",
173
+ "--port", str(LLAMA_SERVER_PORT),
174
+ "-ngl", "99",
175
+ "-c", "8192",
176
+ "--no-mmap",
177
+ "--jinja",
178
+ ]
179
+
180
+ # Check token2wav directory
181
+ t2w_ok = os.path.isdir(paths["token2wav_dir"])
182
+ if t2w_ok:
183
+ t2w_files = os.listdir(paths["token2wav_dir"])
184
+ logger.info(f"[PregoPal] token2wav files ({len(t2w_files)}): {t2w_files}")
185
+ else:
186
+ logger.warning("[PregoPal] token2wav dir NOT FOUND - TTS disabled")
187
+
188
+ @asynccontextmanager
189
+ async def lifespan(web_app: FastAPI):
190
+ """Async lifecycle: start llama-server subprocess, cleanup on shutdown."""
191
+ logger.info("[PregoPal] Starting llama-server...")
192
+ server_process = subprocess.Popen(
193
+ cmd,
194
+ stdout=subprocess.PIPE,
195
+ stderr=subprocess.PIPE,
196
+ text=True,
197
+ )
198
+
199
+ # Poll /health until ready (max 90s)
200
+ base_url = f"http://127.0.0.1:{LLAMA_SERVER_PORT}"
201
+ ready = False
202
+ for i in range(45):
203
+ await asyncio.sleep(2)
204
+ try:
205
+ async with httpx.AsyncClient(timeout=5.0) as client:
206
+ r = await client.get(f"{base_url}/health")
207
+ if r.status_code == 200:
208
+ ready = True
209
+ logger.info(f"[PregoPal] llama-server ready (attempt {i+1})")
210
+ break
211
+ except Exception:
212
+ if i > 0 and i % 5 == 0:
213
+ logger.info(f"[PregoPal] Waiting for llama-server (attempt {i+1})...")
214
+
215
+ if not ready:
216
+ stderr_lines = []
217
+ try:
218
+ for _ in range(20):
219
+ line = server_process.stderr.readline()
220
+ if line:
221
+ stderr_lines.append(line.strip())
222
+ except Exception:
223
+ pass
224
+ logger.error("[PregoPal] llama-server failed to start.\n"
225
+ + "\n".join(stderr_lines[-10:]))
226
+ server_process.terminate()
227
+ raise RuntimeError("llama-server failed to start within 90s")
228
+
229
+ web_app.state.llama_base_url = base_url
230
+ web_app.state.llama_client = httpx.AsyncClient(base_url=base_url, timeout=120.0)
231
+
232
+ yield
233
+
234
+ logger.info("[PregoPal] Shutting down llama-server...")
235
+ server_process.terminate()
236
+ server_process.wait(timeout=30)
237
+ await web_app.state.llama_client.aclose()
238
+ logger.info("[PregoPal] Shutdown complete")
239
+
240
+ web_app = FastAPI(
241
+ title="PregoPal MiniCPM-o-4_5 Omni API",
242
+ lifespan=lifespan,
243
+ )
244
+ web_app.add_middleware(
245
+ CORSMiddleware,
246
+ allow_origins=["*"],
247
+ allow_credentials=True,
248
+ allow_methods=["*"],
249
+ allow_headers=["*"],
250
+ )
251
+
252
+ base_url = f"http://127.0.0.1:{LLAMA_SERVER_PORT}"
253
+
254
+ # ---- Proxy Endpoints ----
255
+
256
+ @web_app.post("/v1/chat/completions")
257
+ async def chat_completions(request: Request):
258
+ body = await request.json()
259
+ stream = body.get("stream", False)
260
+ client = web_app.state.llama_client
261
+
262
+ if stream:
263
+ async def event_stream():
264
+ async with httpx.AsyncClient(timeout=120.0) as sclient:
265
+ async with sclient.stream(
266
+ "POST", f"{base_url}/v1/chat/completions", json=body
267
+ ) as resp:
268
+ async for chunk in resp.aiter_lines():
269
+ if chunk:
270
+ yield chunk + "\n"
271
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
272
+
273
+ try:
274
+ resp = await client.post("/v1/chat/completions", json=body)
275
+ return JSONResponse(resp.json(), status_code=resp.status_code)
276
+ except Exception as e:
277
+ logger.error(f"[PregoPal] Chat completion proxy error: {e}")
278
+ return JSONResponse({"error": str(e)}, status_code=502)
279
+
280
+ @web_app.post("/v1/audio/speech")
281
+ async def audio_speech(request: Request):
282
+ """TTS: text -> speech WAV"""
283
+ body = await request.json()
284
+ client = web_app.state.llama_client
285
+ try:
286
+ resp = await client.post("/v1/audio/speech", json=body)
287
+ return StreamingResponse(
288
+ resp.aiter_bytes(),
289
+ media_type=resp.headers.get("content-type", "audio/wav"),
290
+ )
291
+ except Exception as e:
292
+ logger.error(f"[PregoPal] TTS error: {e}")
293
+ return JSONResponse({"error": str(e)}, status_code=502)
294
+
295
+ @web_app.post("/v1/audio/speech/stream")
296
+ async def audio_speech_stream(request: Request):
297
+ """Streaming TTS"""
298
+ body = await request.json()
299
+ try:
300
+ async with httpx.AsyncClient(timeout=120.0) as sclient:
301
+ async with sclient.stream(
302
+ "POST", f"{base_url}/v1/audio/speech/stream", json=body
303
+ ) as resp:
304
+ async def audio_stream():
305
+ async for chunk in resp.aiter_bytes():
306
+ yield chunk
307
+ return StreamingResponse(
308
+ audio_stream(),
309
+ media_type=resp.headers.get("content-type", "audio/wav"),
310
+ )
311
+ except Exception as e:
312
+ logger.error(f"[PregoPal] Stream TTS error: {e}")
313
+ return JSONResponse({"error": str(e)}, status_code=502)
314
+
315
+ @web_app.post("/v1/audio/transcriptions")
316
+ async def audio_transcriptions(request: Request):
317
+ """STT: speech -> text"""
318
+ body = await request.json()
319
+ client = web_app.state.llama_client
320
+ try:
321
+ resp = await client.post("/v1/audio/transcriptions", json=body)
322
+ return JSONResponse(resp.json(), status_code=resp.status_code)
323
+ except Exception as e:
324
+ logger.error(f"[PregoPal] STT error: {e}")
325
+ return JSONResponse({"error": str(e)}, status_code=502)
326
+
327
+ @web_app.post("/v1/embeddings")
328
+ async def embeddings(request: Request):
329
+ body = await request.json()
330
+ client = web_app.state.llama_client
331
+ try:
332
+ resp = await client.post("/v1/embeddings", json=body)
333
+ return JSONResponse(resp.json(), status_code=resp.status_code)
334
+ except Exception as e:
335
+ logger.error(f"[PregoPal] Embeddings proxy error: {e}")
336
+ return JSONResponse({"error": str(e)}, status_code=502)
337
+
338
+ @web_app.get("/health")
339
+ async def health():
340
+ try:
341
+ client = web_app.state.llama_client
342
+ ls_resp = await client.get("/health")
343
+ ls_status = ls_resp.json()
344
+ except Exception as e:
345
+ ls_status = {"error": str(e)}
346
+ return {
347
+ "status": "ok",
348
+ "model": "MiniCPM-o-4_5",
349
+ "engine": "llama.cpp-omni",
350
+ "cuda": True,
351
+ "vision": os.path.isfile(paths["vision"]),
352
+ "audio": os.path.isfile(paths["audio"]),
353
+ "tts_base_lm": os.path.isfile(paths["tts_base_lm"]),
354
+ "tts_acoustic": os.path.isfile(paths["tts_acoustic"]),
355
+ "token2wav_dir": os.path.isdir(paths["token2wav_dir"]),
356
+ "llama_server_status": ls_status,
357
+ }
358
+
359
+ @web_app.get("/v1/models")
360
+ async def list_models():
361
+ try:
362
+ client = web_app.state.llama_client
363
+ resp = await client.get("/v1/models")
364
+ return JSONResponse(resp.json(), status_code=resp.status_code)
365
+ except Exception:
366
+ return JSONResponse({
367
+ "object": "list",
368
+ "data": [{
369
+ "id": "MiniCPM-o-4_5",
370
+ "object": "model",
371
+ "created": 1,
372
+ "owned_by": "prego-pal",
373
+ }],
374
+ })
375
+
376
+ @web_app.get("/")
377
+ async def root():
378
+ return {
379
+ "service": "PregoPal MiniCPM-o-4_5 Omni API",
380
+ "version": "3.0.0",
381
+ "model": MAIN_GGUF,
382
+ "engine": "llama.cpp-omni (OpenBMB)",
383
+ "endpoints": {
384
+ "chat": "POST /v1/chat/completions (text+multimodal, streaming)",
385
+ "tts": "POST /v1/audio/speech (text->speech)",
386
+ "tts_stream": "POST /v1/audio/speech/stream (streaming TTS)",
387
+ "stt": "POST /v1/audio/transcriptions (speech->text)",
388
+ "embeddings": "POST /v1/embeddings",
389
+ "models": "GET /v1/models",
390
+ "health": "GET /health",
391
+ },
392
+ }
393
+
394
+ return web_app
395
+
396
+
397
+ # ============================================================================
398
+ # 4. DIAGNOSE VOLUME
399
+ # ============================================================================
400
+
401
+ @app.function(
402
+ image=_omni_image,
403
+ volumes={MODEL_DIR: model_volume},
404
+ timeout=120,
405
+ )
406
+ def diagnose_volume():
407
+ """Check model file integrity in Modal Volume."""
408
+ print(f"\n{'='*60}")
409
+ print(f"[Diagnose] {MODEL_SUBDIR}")
410
+ print(f"{'='*60}")
411
+ for root, dirs, files in os.walk(MODEL_SUBDIR):
412
+ level = root.replace(MODEL_SUBDIR, "").count(os.sep)
413
+ indent = " " * 2 * level
414
+ print(f"{indent}{os.path.basename(root)}/")
415
+ subindent = " " * 2 * (level + 1)
416
+ for file in sorted(files):
417
+ fpath = os.path.join(root, file)
418
+ size = os.path.getsize(fpath)
419
+ print(f"{subindent}{file} ({size:,} bytes = {size/1024**3:.2f} GB)")
420
+
421
+ paths = get_model_paths(MODEL_SUBDIR)
422
+ all_ok = True
423
+ for key, path in paths.items():
424
+ if key == "token2wav_dir":
425
+ ok = os.path.isdir(path)
426
+ else:
427
+ ok = os.path.isfile(path)
428
+ status = "OK" if ok else "MISSING"
429
+ if not ok:
430
+ all_ok = False
431
+ print(f" [{status}] {key}: {path}")
432
+
433
+ if all_ok:
434
+ print(f"\n[OK] All model files found! Ready to deploy.")
435
+ else:
436
+ print(f"\n[FAIL] Some files missing. Check uploads.")
437
+
438
+ main_path = paths["main"]
439
+ if os.path.isfile(main_path):
440
+ with open(main_path, "rb") as f:
441
+ magic = f.read(4)
442
+ if magic == b"GGUF":
443
+ print("[OK] Main model is valid GGUF")
444
+ else:
445
+ print(f"[WARN] Main model NOT valid GGUF (magic={magic.hex()})")
446
+
447
+
448
+ # ============================================================================
449
+ # 5. TEST INFERENCE (standalone - not via ASGI)
450
+ # ============================================================================
451
+
452
+ @app.function(
453
+ image=_omni_image,
454
+ volumes={MODEL_DIR: model_volume},
455
+ gpu="T4",
456
+ timeout=600,
457
+ )
458
+ def test_inference():
459
+ """Test llama-server text+multimodal inference on Modal T4."""
460
+ import subprocess
461
+ import time
462
+ import httpx
463
+
464
+ print("[PregoPal] ========== TEST INFERENCE (llama-server) ==========")
465
+
466
+ cmd = [
467
+ "/llama.cpp-omni/build/bin/llama-server",
468
+ "-m", os.path.join(MODEL_SUBDIR, MAIN_GGUF),
469
+ "--mmproj", os.path.join(MODEL_SUBDIR, VISION_MMPROJ),
470
+ "--mmproj", os.path.join(MODEL_SUBDIR, AUDIO_MMPROJ),
471
+ "--host", "127.0.0.1",
472
+ "--port", "8081",
473
+ "-ngl", "99",
474
+ "-c", "4096",
475
+ "--no-mmap",
476
+ ]
477
+
478
+ print("[PregoPal] Starting llama-server...")
479
+ server_proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
480
+
481
+ base_url = "http://127.0.0.1:8081"
482
+ ready = False
483
+ for i in range(30):
484
+ time.sleep(2)
485
+ try:
486
+ r = httpx.get(f"{base_url}/health", timeout=5.0)
487
+ if r.status_code == 200:
488
+ ready = True
489
+ print(f"[PregoPal] llama-server ready (attempt {i+1})")
490
+ break
491
+ except Exception:
492
+ print(f"[PregoPal] Waiting (attempt {i+1})...")
493
+
494
+ if not ready:
495
+ stderr_tail = []
496
+ for _ in range(10):
497
+ line = server_proc.stderr.readline()
498
+ if line:
499
+ stderr_tail.append(line.strip())
500
+ print(f"[PregoPal] Timed out waiting for server.\nstderr:\n" + "\n".join(stderr_tail))
501
+ server_proc.terminate()
502
+ return
503
+
504
+ client = httpx.Client(base_url=base_url, timeout=120.0)
505
+
506
+ try:
507
+ # Test 1: Chinese
508
+ print("\n[Test 1] Chinese...")
509
+ t0 = time.time()
510
+ resp = client.post("/v1/chat/completions", json={
511
+ "messages": [{"role": "user", "content": "Say hello in Chinese, max 10 chars"}],
512
+ "max_tokens": 30, "temperature": 0.1,
513
+ })
514
+ t1 = time.time()
515
+ content = resp.json().get("choices", [{}])[0].get("message", {}).get("content", "")
516
+ print(f"Response ({t1-t0:.1f}s): {content} (status={resp.status_code})")
517
+
518
+ # Test 2: English
519
+ print("\n[Test 2] English...")
520
+ t0 = time.time()
521
+ resp = client.post("/v1/chat/completions", json={
522
+ "messages": [{"role": "user", "content": "What is the capital of France? Answer in 5 words."}],
523
+ "max_tokens": 30, "temperature": 0.1,
524
+ })
525
+ t1 = time.time()
526
+ content = resp.json().get("choices", [{}])[0].get("message", {}).get("content", "")
527
+ print(f"Response ({t1-t0:.1f}s): {content} (status={resp.status_code})")
528
+
529
+ # Test 3: Health
530
+ print("\n[Test 3] Health...")
531
+ resp = client.get("/health")
532
+ info = resp.json()
533
+ print(f"Health: model={info.get('model')}, cuda={info.get('cuda')}, "
534
+ f"vision={info.get('vision')}, audio={info.get('audio')}")
535
+
536
+ print(f"\n{'='*50}")
537
+ print("[OK] All tests passed!")
538
+ print(f"{'='*50}")
539
+
540
+ except Exception as e:
541
+ print(f"[PregoPal] Test error: {e}")
542
+ raise
543
+ finally:
544
+ server_proc.terminate()
545
+ server_proc.wait(timeout=10)
546
+
547
+
548
+ # ============================================================================
549
+ # 6. LOCAL ENTRY POINT
550
+ # ============================================================================
551
+
552
+ if __name__ == "__main__":
553
+ import sys
554
+ if len(sys.argv) > 1:
555
+ if sys.argv[1] == "test_inference":
556
+ test_inference.local()
557
+ elif sys.argv[1] == "diagnose_volume":
558
+ diagnose_volume.local()
modal_deploy/deploy_omni_bak_v1.py ADDED
@@ -0,0 +1,556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PregoPal x MiniCPM-o-4_5 - Modal deploy (llama.cpp-omni full-duplex voice upgrade)
3
+
4
+ Architecture:
5
+ FastAPI (ASGI) <-> llama-server (OpenBMB/llama.cpp-omni subprocess)
6
+ |
7
+ Modal Volume: GGUF models (vision + audio + TTS)
8
+
9
+ Usage:
10
+ pip install modal
11
+ modal token new
12
+ modal deploy modal_deploy.deploy_omni
13
+
14
+ Test:
15
+ modal run -m modal_deploy.deploy_omni::test_inference
16
+ modal run -m modal_deploy.deploy_omni::diagnose_volume
17
+
18
+ API:
19
+ POST /v1/chat/completions - OpenAI compatible (text + multimodal, streaming)
20
+ POST /v1/audio/speech - TTS: text -> voice WAV
21
+ POST /v1/audio/transcriptions - STT: voice -> text
22
+ POST /v1/embeddings - Embeddings
23
+ GET /health - Health check (audio/vision/TTS status)
24
+ GET /v1/models - Model list
25
+ """
26
+
27
+ import os
28
+ import modal
29
+ from modal import Image, App, Volume, asgi_app
30
+
31
+ # ============================================================================
32
+ # 1. IMAGE - Build OpenBMB/llama.cpp-omni from source
33
+ # Source is copied from local llamacpp_omni/ (repo no longer public on GitHub)
34
+ # ============================================================================
35
+
36
+ _omni_image = (
37
+ Image.debian_slim(python_version="3.11")
38
+ .apt_install(
39
+ "curl",
40
+ )
41
+ # Install CUDA Toolkit for compiling llama.cpp CUDA kernels
42
+ .run_commands(
43
+ "curl -L -o /tmp/cuda-keyring.deb https://developer.download.nvidia.com/compute/cuda/repos/debian12/x86_64/cuda-keyring_1.1-1_all.deb",
44
+ "dpkg -i /tmp/cuda-keyring.deb",
45
+ "apt-get update",
46
+ "apt-get install -y cuda-toolkit-12-4 cuda-compiler-12-4",
47
+ )
48
+ .apt_install(
49
+ "curl",
50
+ "git",
51
+ "build-essential",
52
+ "cmake",
53
+ "libcurl4-openssl-dev",
54
+ "libsndfile1",
55
+ "libasound2-dev",
56
+ "pkg-config",
57
+ )
58
+ .pip_install(
59
+ "fastapi",
60
+ "uvicorn[standard]",
61
+ "httpx",
62
+ "numpy",
63
+ "Pillow",
64
+ "soundfile",
65
+ )
66
+ # Copy local llamacpp_omni source into image (repo no longer public)
67
+ .add_local_dir(
68
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "llamacpp_omni"),
69
+ "/llama.cpp-omni",
70
+ copy=True,
71
+ )
72
+ .run_commands(
73
+ "cd /llama.cpp-omni && cmake -B build "
74
+ "-DGGML_CUDA=ON "
75
+ "-DLLAMA_CURL=ON "
76
+ "-DLLAMA_BUILD_SERVER=ON "
77
+ "-DLLAMA_BUILD_TESTS=OFF "
78
+ "-DLLAMA_BUILD_EXAMPLES=OFF "
79
+ "-DCMAKE_BUILD_TYPE=Release "
80
+ "-DCMAKE_CUDA_COMPILER=/usr/local/cuda-12/bin/nvcc",
81
+ "cd /llama.cpp-omni && cmake --build build --config Release -j $(nproc) "
82
+ "--target llama-server llama-mtmd-cli",
83
+ "ls -lh /llama.cpp-omni/build/bin/llama-server /llama.cpp-omni/build/bin/llama-mtmd-cli",
84
+ )
85
+ )
86
+
87
+ # ============================================================================
88
+ # 2. CONSTANTS
89
+ # ============================================================================
90
+
91
+ MODEL_DIR = "/models"
92
+ MODEL_SUBDIR = f"{MODEL_DIR}/MiniCPM-o-4_5-gguf"
93
+
94
+ MAIN_GGUF = "MiniCPM-o-4_5-Q4_K_M.gguf"
95
+ VISION_MMPROJ = "vision/MiniCPM-o-4_5-vision-F16.gguf"
96
+ AUDIO_MMPROJ = "audio/MiniCPM-o-4_5-audio-F16.gguf"
97
+ TTS_BASE_LM = "tts/MiniCPM-o-4_5-tts-F16.gguf"
98
+ TTS_ACOUSTIC = "tts/MiniCPM-o-4_5-projector-F16.gguf"
99
+ TOKEN2WAV_DIR = "token2wav-gguf"
100
+
101
+ LLAMA_SERVER_PORT = 8081
102
+
103
+ model_volume = Volume.from_name("minicpm-o-4_5-models", create_if_missing=True)
104
+ app = App("prego-pal-minicpm-omni")
105
+
106
+
107
+ def get_model_paths(base_dir: str) -> dict:
108
+ paths = {
109
+ "main": os.path.join(base_dir, MAIN_GGUF),
110
+ "vision": os.path.join(base_dir, VISION_MMPROJ),
111
+ "audio": os.path.join(base_dir, AUDIO_MMPROJ),
112
+ "tts_base_lm": os.path.join(base_dir, TTS_BASE_LM),
113
+ "tts_acoustic": os.path.join(base_dir, TTS_ACOUSTIC),
114
+ "token2wav_dir": os.path.join(base_dir, TOKEN2WAV_DIR),
115
+ }
116
+ for key, path in paths.items():
117
+ if key == "token2wav_dir":
118
+ exists = os.path.isdir(path)
119
+ else:
120
+ exists = os.path.isfile(path)
121
+ print(f"[PregoPal] {key}: {path} (exists={exists})")
122
+ return paths
123
+
124
+
125
+ # ============================================================================
126
+ # 3. ASGI APP - FastAPI lifespan + llama-server subprocess
127
+ # ============================================================================
128
+
129
+ @app.function(
130
+ image=_omni_image,
131
+ volumes={MODEL_DIR: model_volume},
132
+ gpu="T4",
133
+ timeout=1200,
134
+ scaledown_window=300,
135
+ )
136
+ @modal.concurrent(max_inputs=10)
137
+ @asgi_app()
138
+ def serve():
139
+ """
140
+ FastAPI ASGI app. Launches llama-server subprocess in lifespan.
141
+ serve() is sync; async logic lives in lifespan context manager.
142
+ """
143
+ import asyncio
144
+ import json
145
+ import logging
146
+ import subprocess
147
+ from contextlib import asynccontextmanager
148
+ from fastapi import FastAPI, Request
149
+ from fastapi.responses import StreamingResponse, JSONResponse
150
+ from fastapi.middleware.cors import CORSMiddleware
151
+ import httpx
152
+
153
+ logging.basicConfig(level=logging.INFO)
154
+ logger = logging.getLogger("prego-pal-omni")
155
+
156
+ paths = get_model_paths(MODEL_SUBDIR)
157
+
158
+ # Build llama-server command
159
+ llama_server_bin = "/llama.cpp-omni/build/bin/llama-server"
160
+ if not os.path.isfile(llama_server_bin):
161
+ llama_server_bin = "/llama.cpp-omni/build/bin/Release/llama-server"
162
+
163
+ cmd = [
164
+ llama_server_bin,
165
+ "-m", paths["main"],
166
+ "--mmproj", paths["vision"],
167
+ "--mmproj", paths["audio"],
168
+ "--voxcpm2-base-lm", paths["tts_base_lm"],
169
+ "--voxcpm2-acoustic", paths["tts_acoustic"],
170
+ "--host", "127.0.0.1",
171
+ "--port", str(LLAMA_SERVER_PORT),
172
+ "-ngl", "99",
173
+ "-c", "8192",
174
+ "--no-mmap",
175
+ "--jinja",
176
+ ]
177
+
178
+ # Check token2wav directory
179
+ t2w_ok = os.path.isdir(paths["token2wav_dir"])
180
+ if t2w_ok:
181
+ t2w_files = os.listdir(paths["token2wav_dir"])
182
+ logger.info(f"[PregoPal] token2wav files ({len(t2w_files)}): {t2w_files}")
183
+ else:
184
+ logger.warning("[PregoPal] token2wav dir NOT FOUND - TTS disabled")
185
+
186
+ @asynccontextmanager
187
+ async def lifespan(web_app: FastAPI):
188
+ """Async lifecycle: start llama-server subprocess, cleanup on shutdown."""
189
+ logger.info("[PregoPal] Starting llama-server...")
190
+ server_process = subprocess.Popen(
191
+ cmd,
192
+ stdout=subprocess.PIPE,
193
+ stderr=subprocess.PIPE,
194
+ text=True,
195
+ )
196
+
197
+ # Poll /health until ready (max 90s)
198
+ base_url = f"http://127.0.0.1:{LLAMA_SERVER_PORT}"
199
+ ready = False
200
+ for i in range(45):
201
+ await asyncio.sleep(2)
202
+ try:
203
+ async with httpx.AsyncClient(timeout=5.0) as client:
204
+ r = await client.get(f"{base_url}/health")
205
+ if r.status_code == 200:
206
+ ready = True
207
+ logger.info(f"[PregoPal] llama-server ready (attempt {i+1})")
208
+ break
209
+ except Exception:
210
+ if i > 0 and i % 5 == 0:
211
+ logger.info(f"[PregoPal] Waiting for llama-server (attempt {i+1})...")
212
+
213
+ if not ready:
214
+ stderr_lines = []
215
+ try:
216
+ for _ in range(20):
217
+ line = server_process.stderr.readline()
218
+ if line:
219
+ stderr_lines.append(line.strip())
220
+ except Exception:
221
+ pass
222
+ logger.error("[PregoPal] llama-server failed to start.\n"
223
+ + "\n".join(stderr_lines[-10:]))
224
+ server_process.terminate()
225
+ raise RuntimeError("llama-server failed to start within 90s")
226
+
227
+ web_app.state.llama_base_url = base_url
228
+ web_app.state.llama_client = httpx.AsyncClient(base_url=base_url, timeout=120.0)
229
+
230
+ yield
231
+
232
+ logger.info("[PregoPal] Shutting down llama-server...")
233
+ server_process.terminate()
234
+ server_process.wait(timeout=30)
235
+ await web_app.state.llama_client.aclose()
236
+ logger.info("[PregoPal] Shutdown complete")
237
+
238
+ web_app = FastAPI(
239
+ title="PregoPal MiniCPM-o-4_5 Omni API",
240
+ lifespan=lifespan,
241
+ )
242
+ web_app.add_middleware(
243
+ CORSMiddleware,
244
+ allow_origins=["*"],
245
+ allow_credentials=True,
246
+ allow_methods=["*"],
247
+ allow_headers=["*"],
248
+ )
249
+
250
+ base_url = f"http://127.0.0.1:{LLAMA_SERVER_PORT}"
251
+
252
+ # ---- Proxy Endpoints ----
253
+
254
+ @web_app.post("/v1/chat/completions")
255
+ async def chat_completions(request: Request):
256
+ body = await request.json()
257
+ stream = body.get("stream", False)
258
+ client = web_app.state.llama_client
259
+
260
+ if stream:
261
+ async def event_stream():
262
+ async with httpx.AsyncClient(timeout=120.0) as sclient:
263
+ async with sclient.stream(
264
+ "POST", f"{base_url}/v1/chat/completions", json=body
265
+ ) as resp:
266
+ async for chunk in resp.aiter_lines():
267
+ if chunk:
268
+ yield chunk + "\n"
269
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
270
+
271
+ try:
272
+ resp = await client.post("/v1/chat/completions", json=body)
273
+ return JSONResponse(resp.json(), status_code=resp.status_code)
274
+ except Exception as e:
275
+ logger.error(f"[PregoPal] Chat completion proxy error: {e}")
276
+ return JSONResponse({"error": str(e)}, status_code=502)
277
+
278
+ @web_app.post("/v1/audio/speech")
279
+ async def audio_speech(request: Request):
280
+ """TTS: text -> speech WAV"""
281
+ body = await request.json()
282
+ client = web_app.state.llama_client
283
+ try:
284
+ resp = await client.post("/v1/audio/speech", json=body)
285
+ return StreamingResponse(
286
+ resp.aiter_bytes(),
287
+ media_type=resp.headers.get("content-type", "audio/wav"),
288
+ )
289
+ except Exception as e:
290
+ logger.error(f"[PregoPal] TTS error: {e}")
291
+ return JSONResponse({"error": str(e)}, status_code=502)
292
+
293
+ @web_app.post("/v1/audio/speech/stream")
294
+ async def audio_speech_stream(request: Request):
295
+ """Streaming TTS"""
296
+ body = await request.json()
297
+ try:
298
+ async with httpx.AsyncClient(timeout=120.0) as sclient:
299
+ async with sclient.stream(
300
+ "POST", f"{base_url}/v1/audio/speech/stream", json=body
301
+ ) as resp:
302
+ async def audio_stream():
303
+ async for chunk in resp.aiter_bytes():
304
+ yield chunk
305
+ return StreamingResponse(
306
+ audio_stream(),
307
+ media_type=resp.headers.get("content-type", "audio/wav"),
308
+ )
309
+ except Exception as e:
310
+ logger.error(f"[PregoPal] Stream TTS error: {e}")
311
+ return JSONResponse({"error": str(e)}, status_code=502)
312
+
313
+ @web_app.post("/v1/audio/transcriptions")
314
+ async def audio_transcriptions(request: Request):
315
+ """STT: speech -> text"""
316
+ body = await request.json()
317
+ client = web_app.state.llama_client
318
+ try:
319
+ resp = await client.post("/v1/audio/transcriptions", json=body)
320
+ return JSONResponse(resp.json(), status_code=resp.status_code)
321
+ except Exception as e:
322
+ logger.error(f"[PregoPal] STT error: {e}")
323
+ return JSONResponse({"error": str(e)}, status_code=502)
324
+
325
+ @web_app.post("/v1/embeddings")
326
+ async def embeddings(request: Request):
327
+ body = await request.json()
328
+ client = web_app.state.llama_client
329
+ try:
330
+ resp = await client.post("/v1/embeddings", json=body)
331
+ return JSONResponse(resp.json(), status_code=resp.status_code)
332
+ except Exception as e:
333
+ logger.error(f"[PregoPal] Embeddings proxy error: {e}")
334
+ return JSONResponse({"error": str(e)}, status_code=502)
335
+
336
+ @web_app.get("/health")
337
+ async def health():
338
+ try:
339
+ client = web_app.state.llama_client
340
+ ls_resp = await client.get("/health")
341
+ ls_status = ls_resp.json()
342
+ except Exception as e:
343
+ ls_status = {"error": str(e)}
344
+ return {
345
+ "status": "ok",
346
+ "model": "MiniCPM-o-4_5",
347
+ "engine": "llama.cpp-omni",
348
+ "cuda": True,
349
+ "vision": os.path.isfile(paths["vision"]),
350
+ "audio": os.path.isfile(paths["audio"]),
351
+ "tts_base_lm": os.path.isfile(paths["tts_base_lm"]),
352
+ "tts_acoustic": os.path.isfile(paths["tts_acoustic"]),
353
+ "token2wav_dir": os.path.isdir(paths["token2wav_dir"]),
354
+ "llama_server_status": ls_status,
355
+ }
356
+
357
+ @web_app.get("/v1/models")
358
+ async def list_models():
359
+ try:
360
+ client = web_app.state.llama_client
361
+ resp = await client.get("/v1/models")
362
+ return JSONResponse(resp.json(), status_code=resp.status_code)
363
+ except Exception:
364
+ return JSONResponse({
365
+ "object": "list",
366
+ "data": [{
367
+ "id": "MiniCPM-o-4_5",
368
+ "object": "model",
369
+ "created": 1,
370
+ "owned_by": "prego-pal",
371
+ }],
372
+ })
373
+
374
+ @web_app.get("/")
375
+ async def root():
376
+ return {
377
+ "service": "PregoPal MiniCPM-o-4_5 Omni API",
378
+ "version": "3.0.0",
379
+ "model": MAIN_GGUF,
380
+ "engine": "llama.cpp-omni (OpenBMB)",
381
+ "endpoints": {
382
+ "chat": "POST /v1/chat/completions (text+multimodal, streaming)",
383
+ "tts": "POST /v1/audio/speech (text->speech)",
384
+ "tts_stream": "POST /v1/audio/speech/stream (streaming TTS)",
385
+ "stt": "POST /v1/audio/transcriptions (speech->text)",
386
+ "embeddings": "POST /v1/embeddings",
387
+ "models": "GET /v1/models",
388
+ "health": "GET /health",
389
+ },
390
+ }
391
+
392
+ return web_app
393
+
394
+
395
+ # ============================================================================
396
+ # 4. DIAGNOSE VOLUME
397
+ # ============================================================================
398
+
399
+ @app.function(
400
+ image=_omni_image,
401
+ volumes={MODEL_DIR: model_volume},
402
+ timeout=120,
403
+ )
404
+ def diagnose_volume():
405
+ """Check model file integrity in Modal Volume."""
406
+ print(f"\n{'='*60}")
407
+ print(f"[Diagnose] {MODEL_SUBDIR}")
408
+ print(f"{'='*60}")
409
+ for root, dirs, files in os.walk(MODEL_SUBDIR):
410
+ level = root.replace(MODEL_SUBDIR, "").count(os.sep)
411
+ indent = " " * 2 * level
412
+ print(f"{indent}{os.path.basename(root)}/")
413
+ subindent = " " * 2 * (level + 1)
414
+ for file in sorted(files):
415
+ fpath = os.path.join(root, file)
416
+ size = os.path.getsize(fpath)
417
+ print(f"{subindent}{file} ({size:,} bytes = {size/1024**3:.2f} GB)")
418
+
419
+ paths = get_model_paths(MODEL_SUBDIR)
420
+ all_ok = True
421
+ for key, path in paths.items():
422
+ if key == "token2wav_dir":
423
+ ok = os.path.isdir(path)
424
+ else:
425
+ ok = os.path.isfile(path)
426
+ status = "OK" if ok else "MISSING"
427
+ if not ok:
428
+ all_ok = False
429
+ print(f" [{status}] {key}: {path}")
430
+
431
+ if all_ok:
432
+ print(f"\n[OK] All model files found! Ready to deploy.")
433
+ else:
434
+ print(f"\n[FAIL] Some files missing. Check uploads.")
435
+
436
+ main_path = paths["main"]
437
+ if os.path.isfile(main_path):
438
+ with open(main_path, "rb") as f:
439
+ magic = f.read(4)
440
+ if magic == b"GGUF":
441
+ print("[OK] Main model is valid GGUF")
442
+ else:
443
+ print(f"[WARN] Main model NOT valid GGUF (magic={magic.hex()})")
444
+
445
+
446
+ # ============================================================================
447
+ # 5. TEST INFERENCE (standalone - not via ASGI)
448
+ # ============================================================================
449
+
450
+ @app.function(
451
+ image=_omni_image,
452
+ volumes={MODEL_DIR: model_volume},
453
+ gpu="T4",
454
+ timeout=600,
455
+ )
456
+ def test_inference():
457
+ """Test llama-server text+multimodal inference on Modal T4."""
458
+ import subprocess
459
+ import time
460
+ import httpx
461
+
462
+ print("[PregoPal] ========== TEST INFERENCE (llama-server) ==========")
463
+
464
+ cmd = [
465
+ "/llama.cpp-omni/build/bin/llama-server",
466
+ "-m", os.path.join(MODEL_SUBDIR, MAIN_GGUF),
467
+ "--mmproj", os.path.join(MODEL_SUBDIR, VISION_MMPROJ),
468
+ "--mmproj", os.path.join(MODEL_SUBDIR, AUDIO_MMPROJ),
469
+ "--host", "127.0.0.1",
470
+ "--port", "8081",
471
+ "-ngl", "99",
472
+ "-c", "4096",
473
+ "--no-mmap",
474
+ ]
475
+
476
+ print("[PregoPal] Starting llama-server...")
477
+ server_proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
478
+
479
+ base_url = "http://127.0.0.1:8081"
480
+ ready = False
481
+ for i in range(30):
482
+ time.sleep(2)
483
+ try:
484
+ r = httpx.get(f"{base_url}/health", timeout=5.0)
485
+ if r.status_code == 200:
486
+ ready = True
487
+ print(f"[PregoPal] llama-server ready (attempt {i+1})")
488
+ break
489
+ except Exception:
490
+ print(f"[PregoPal] Waiting (attempt {i+1})...")
491
+
492
+ if not ready:
493
+ stderr_tail = []
494
+ for _ in range(10):
495
+ line = server_proc.stderr.readline()
496
+ if line:
497
+ stderr_tail.append(line.strip())
498
+ print(f"[PregoPal] Timed out waiting for server.\nstderr:\n" + "\n".join(stderr_tail))
499
+ server_proc.terminate()
500
+ return
501
+
502
+ client = httpx.Client(base_url=base_url, timeout=120.0)
503
+
504
+ try:
505
+ # Test 1: Chinese
506
+ print("\n[Test 1] Chinese...")
507
+ t0 = time.time()
508
+ resp = client.post("/v1/chat/completions", json={
509
+ "messages": [{"role": "user", "content": "Say hello in Chinese, max 10 chars"}],
510
+ "max_tokens": 30, "temperature": 0.1,
511
+ })
512
+ t1 = time.time()
513
+ content = resp.json().get("choices", [{}])[0].get("message", {}).get("content", "")
514
+ print(f"Response ({t1-t0:.1f}s): {content} (status={resp.status_code})")
515
+
516
+ # Test 2: English
517
+ print("\n[Test 2] English...")
518
+ t0 = time.time()
519
+ resp = client.post("/v1/chat/completions", json={
520
+ "messages": [{"role": "user", "content": "What is the capital of France? Answer in 5 words."}],
521
+ "max_tokens": 30, "temperature": 0.1,
522
+ })
523
+ t1 = time.time()
524
+ content = resp.json().get("choices", [{}])[0].get("message", {}).get("content", "")
525
+ print(f"Response ({t1-t0:.1f}s): {content} (status={resp.status_code})")
526
+
527
+ # Test 3: Health
528
+ print("\n[Test 3] Health...")
529
+ resp = client.get("/health")
530
+ info = resp.json()
531
+ print(f"Health: model={info.get('model')}, cuda={info.get('cuda')}, "
532
+ f"vision={info.get('vision')}, audio={info.get('audio')}")
533
+
534
+ print(f"\n{'='*50}")
535
+ print("[OK] All tests passed!")
536
+ print(f"{'='*50}")
537
+
538
+ except Exception as e:
539
+ print(f"[PregoPal] Test error: {e}")
540
+ raise
541
+ finally:
542
+ server_proc.terminate()
543
+ server_proc.wait(timeout=10)
544
+
545
+
546
+ # ============================================================================
547
+ # 6. LOCAL ENTRY POINT
548
+ # ============================================================================
549
+
550
+ if __name__ == "__main__":
551
+ import sys
552
+ if len(sys.argv) > 1:
553
+ if sys.argv[1] == "test_inference":
554
+ test_inference.local()
555
+ elif sys.argv[1] == "diagnose_volume":
556
+ diagnose_volume.local()
modal_deploy/llamacpp_omni ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit da241979b95b13e747f7c3f8d6821930e0263d33