Spaces:
Runtime error
Runtime error
| """ | |
| PregoPal x MiniCPM-o-4_5 - Modal deploy (llama.cpp-omni full-duplex voice upgrade) | |
| Architecture: | |
| FastAPI (ASGI) <-> llama-server (OpenBMB/llama.cpp-omni subprocess) | |
| | | |
| Modal Volume: GGUF models (vision + audio + TTS) | |
| Usage: | |
| pip install modal | |
| modal token new | |
| modal deploy modal_deploy.deploy_omni | |
| Test: | |
| modal run -m modal_deploy.deploy_omni::test_inference | |
| modal run -m modal_deploy.deploy_omni::diagnose_volume | |
| API: | |
| POST /v1/chat/completions - OpenAI compatible (text + multimodal, streaming) | |
| POST /v1/audio/speech - TTS: text -> voice WAV | |
| POST /v1/audio/transcriptions - STT: voice -> text | |
| POST /v1/embeddings - Embeddings | |
| GET /health - Health check (audio/vision/TTS status) | |
| GET /v1/models - Model list | |
| """ | |
| import os | |
| import modal | |
| from modal import Image, App, Volume, asgi_app | |
| # ============================================================================ | |
| # 1. IMAGE - Build OpenBMB/llama.cpp-omni from source | |
| # Source is copied from local llamacpp_omni/ (repo no longer public on GitHub) | |
| # ============================================================================ | |
| _omni_image = ( | |
| Image.debian_slim(python_version="3.11") | |
| .apt_install( | |
| "curl", | |
| ) | |
| # Install CUDA Toolkit for compiling llama.cpp CUDA kernels | |
| .run_commands( | |
| "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", | |
| "dpkg -i /tmp/cuda-keyring.deb", | |
| "apt-get update", | |
| "apt-get install -y cuda-toolkit-12-4 cuda-compiler-12-4 cuda-driver-dev-12-4", | |
| ) | |
| .apt_install( | |
| "curl", | |
| "git", | |
| "build-essential", | |
| "cmake", | |
| "libcurl4-openssl-dev", | |
| "libsndfile1", | |
| "libasound2-dev", | |
| "pkg-config", | |
| ) | |
| .pip_install( | |
| "fastapi", | |
| "uvicorn[standard]", | |
| "httpx", | |
| "numpy", | |
| "Pillow", | |
| "soundfile", | |
| ) | |
| # Copy local llamacpp_omni source into image (repo no longer public) | |
| .add_local_dir( | |
| os.path.join(os.path.dirname(os.path.abspath(__file__)), "llamacpp_omni"), | |
| "/llama.cpp-omni", | |
| copy=True, | |
| ) | |
| .run_commands( | |
| "cd /llama.cpp-omni && cmake -B build " | |
| "-DGGML_CUDA=ON " | |
| "-DLLAMA_BUILD_SERVER=ON " | |
| "-DLLAMA_BUILD_TESTS=OFF " | |
| "-DLLAMA_BUILD_EXAMPLES=OFF " | |
| "-DLLAMA_CUDA_FORCE_MMQ=ON " | |
| "-DGGML_CUDA_NO_VMM=ON " | |
| "-DCMAKE_CUDA_ARCHITECTURES='75;89' " | |
| "-DCMAKE_BUILD_TYPE=Release " | |
| "-DCMAKE_CUDA_COMPILER=/usr/local/cuda-12/bin/nvcc", | |
| # Build llama-server first (includes all CUDA kernels + main libs) | |
| "cd /llama.cpp-omni && cmake --build build --config Release -j $(nproc) --target llama-server", | |
| # Then build llama-omni-server (links against already compiled libs) | |
| "cd /llama.cpp-omni && touch tools/server/server-omni.cpp", | |
| "cd /llama.cpp-omni && cmake --build build --config Release -j $(nproc) --target llama-omni-server", | |
| "ls -lh /llama.cpp-omni/build/bin/llama-server /llama.cpp-omni/build/bin/llama-omni-server", | |
| ) | |
| ) | |
| # ============================================================================ | |
| # 2. CONSTANTS | |
| # ============================================================================ | |
| MODEL_DIR = "/models" | |
| MODEL_SUBDIR = f"{MODEL_DIR}/MiniCPM-o-4_5-gguf" | |
| MAIN_GGUF = "MiniCPM-o-4_5-Q4_K_M.gguf" | |
| VISION_MMPROJ = "vision/MiniCPM-o-4_5-vision-F16.gguf" | |
| AUDIO_MMPROJ = "audio/MiniCPM-o-4_5-audio-F16.gguf" | |
| TTS_BASE_LM = "tts/MiniCPM-o-4_5-tts-F16.gguf" | |
| TTS_ACOUSTIC = "tts/MiniCPM-o-4_5-projector-F16.gguf" | |
| TOKEN2WAV_DIR = "token2wav-gguf" | |
| LLAMA_SERVER_PORT = 8081 | |
| OMNI_SERVER_PORT = 8082 | |
| OMNI_TTS_WAV_DIR = "/tmp/omni_output" | |
| model_volume = Volume.from_name("minicpm-o-4_5-models", create_if_missing=True) | |
| app = App("prego-pal-minicpm-omni") | |
| def get_model_paths(base_dir: str) -> dict: | |
| paths = { | |
| "main": os.path.join(base_dir, MAIN_GGUF), | |
| "vision": os.path.join(base_dir, VISION_MMPROJ), | |
| "audio": os.path.join(base_dir, AUDIO_MMPROJ), | |
| "tts_base_lm": os.path.join(base_dir, TTS_BASE_LM), | |
| "tts_acoustic": os.path.join(base_dir, TTS_ACOUSTIC), | |
| "token2wav_dir": os.path.join(base_dir, TOKEN2WAV_DIR), | |
| } | |
| for key, path in paths.items(): | |
| if key == "token2wav_dir": | |
| exists = os.path.isdir(path) | |
| else: | |
| exists = os.path.isfile(path) | |
| print(f"[PregoPal] {key}: {path} (exists={exists})") | |
| return paths | |
| # ============================================================================ | |
| # 3. ASGI APP - FastAPI lifespan + llama-server + llama-omni-server | |
| # ============================================================================ | |
| def serve(): | |
| """ | |
| FastAPI ASGI app. Launches llama-server + llama-omni-server subprocesses. | |
| serve() is sync; async logic lives in lifespan context manager. | |
| """ | |
| import asyncio | |
| import json | |
| import logging | |
| import subprocess | |
| from contextlib import asynccontextmanager | |
| from fastapi import FastAPI, Request | |
| from fastapi.responses import StreamingResponse, JSONResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| import httpx | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger("prego-pal-omni") | |
| paths = get_model_paths(MODEL_SUBDIR) | |
| # Build llama-server command | |
| llama_server_bin = "/llama.cpp-omni/build/bin/llama-server" | |
| if not os.path.isfile(llama_server_bin): | |
| llama_server_bin = "/llama.cpp-omni/build/bin/Release/llama-server" | |
| cmd = [ | |
| llama_server_bin, | |
| "-m", paths["main"], | |
| "--mmproj", paths["vision"], | |
| "--host", "127.0.0.1", | |
| "--port", str(LLAMA_SERVER_PORT), | |
| "-ngl", "99", | |
| "-c", "8192", | |
| "--no-mmap", | |
| "--jinja", | |
| ] | |
| # llama-omni-server binary (standalone omni HTTP API for full-duplex) | |
| omni_server_bin = "/llama.cpp-omni/build/bin/llama-omni-server" | |
| if not os.path.isfile(omni_server_bin): | |
| omni_server_bin = "/llama.cpp-omni/build/bin/Release/llama-omni-server" | |
| omni_cmd = [ | |
| omni_server_bin, | |
| "--port", str(OMNI_SERVER_PORT), | |
| "--host", "127.0.0.1", | |
| "-ngl", "99", | |
| ] | |
| # Check token2wav and TTS model directories | |
| # llama-omni-server auto-detects model files from model_dir | |
| # via omni_init API body. No need to pass them as CLI args. | |
| async def lifespan(web_app: FastAPI): | |
| """Async lifecycle: start both subprocesses, cleanup on shutdown.""" | |
| processes = [] | |
| # Start llama-server (standard chat/tts/stt) | |
| logger.info("[PregoPal] Starting llama-server...") | |
| ls_proc = subprocess.Popen( | |
| cmd, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| text=True, | |
| ) | |
| processes.append(("llama-server", ls_proc)) | |
| # Start llama-omni-server (full-duplex omni API) | |
| logger.info(f"[PregoPal] Starting llama-omni-server on port {OMNI_SERVER_PORT}...") | |
| os.makedirs(OMNI_TTS_WAV_DIR, exist_ok=True) | |
| omni_proc = subprocess.Popen( | |
| omni_cmd, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| text=True, | |
| ) | |
| processes.append(("llama-omni-server", omni_proc)) | |
| base_url = f"http://127.0.0.1:{LLAMA_SERVER_PORT}" | |
| omni_url = f"http://127.0.0.1:{OMNI_SERVER_PORT}" | |
| ls_ready = False | |
| omni_ready = False | |
| for i in range(45): | |
| await asyncio.sleep(2) | |
| try: | |
| async with httpx.AsyncClient(timeout=5.0) as client: | |
| if not ls_ready: | |
| r = await client.get(f"{base_url}/health") | |
| if r.status_code == 200: | |
| ls_ready = True | |
| logger.info(f"[PregoPal] llama-server ready (attempt {i+1})") | |
| if not omni_ready: | |
| try: | |
| r2 = await client.get(f"{omni_url}/health") | |
| if r2.status_code == 200: | |
| omni_ready = True | |
| logger.info(f"[PregoPal] llama-omni-server ready (attempt {i+1})") | |
| except Exception: | |
| pass | |
| except Exception: | |
| if i > 0 and i % 5 == 0: | |
| logger.info(f"[PregoPal] Waiting (llama-server={ls_ready}, omni-server={omni_ready})...") | |
| if not ls_ready: | |
| for name, proc in processes: | |
| stderr_lines = [] | |
| try: | |
| for _ in range(20): | |
| line = proc.stderr.readline() | |
| if line: | |
| stderr_lines.append(line.strip()) | |
| except Exception: | |
| pass | |
| logger.error(f"[PregoPal] {name} stderr:\n" + "\n".join(stderr_lines[-10:])) | |
| proc.terminate() | |
| raise RuntimeError("llama-server failed to start within 90s") | |
| if not omni_ready: | |
| logger.warning("[PregoPal] llama-omni-server not ready - omni endpoints will be unavailable") | |
| web_app.state.llama_base_url = base_url | |
| web_app.state.llama_base_url = omni_url | |
| web_app.state.llama_client = httpx.AsyncClient(base_url=base_url, timeout=120.0) | |
| web_app.state.llama_client = httpx.AsyncClient(base_url=omni_url, timeout=600.0) | |
| web_app.state.omni_temp_dir = OMNI_TTS_WAV_DIR | |
| yield | |
| logger.info("[PregoPal] Shutting down...") | |
| for name, proc in processes: | |
| logger.info(f"[PregoPal] Terminating {name}...") | |
| proc.terminate() | |
| proc.wait(timeout=30) | |
| await web_app.state.llama_client.aclose() | |
| logger.info("[PregoPal] Shutdown complete") | |
| web_app = FastAPI( | |
| title="PregoPal MiniCPM-o-4_5 Omni API", | |
| lifespan=lifespan, | |
| ) | |
| web_app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| base_url = f"http://127.0.0.1:{LLAMA_SERVER_PORT}" | |
| # ---- Proxy Endpoints ---- | |
| async def chat_completions(request: Request): | |
| body = await request.json() | |
| stream = body.get("stream", False) | |
| client = web_app.state.llama_client | |
| if stream: | |
| async def event_stream(): | |
| async with httpx.AsyncClient(timeout=120.0) as sclient: | |
| async with sclient.stream( | |
| "POST", f"{base_url}/v1/chat/completions", json=body | |
| ) as resp: | |
| async for chunk in resp.aiter_lines(): | |
| if chunk: | |
| yield chunk + "\n" | |
| return StreamingResponse(event_stream(), media_type="text/event-stream") | |
| try: | |
| resp = await client.post("/v1/chat/completions", json=body) | |
| return JSONResponse(resp.json(), status_code=resp.status_code) | |
| except Exception as e: | |
| logger.error(f"[PregoPal] Chat completion proxy error: {e}") | |
| return JSONResponse({"error": str(e)}, status_code=502) | |
| async def audio_speech(request: Request): | |
| """TTS: text -> speech WAV""" | |
| body = await request.json() | |
| client = web_app.state.llama_client | |
| try: | |
| resp = await client.post("/v1/audio/speech", json=body) | |
| return StreamingResponse( | |
| resp.aiter_bytes(), | |
| media_type=resp.headers.get("content-type", "audio/wav"), | |
| ) | |
| except Exception as e: | |
| logger.error(f"[PregoPal] TTS error: {e}") | |
| return JSONResponse({"error": str(e)}, status_code=502) | |
| async def audio_speech_stream(request: Request): | |
| """Streaming TTS""" | |
| body = await request.json() | |
| try: | |
| async with httpx.AsyncClient(timeout=120.0) as sclient: | |
| async with sclient.stream( | |
| "POST", f"{base_url}/v1/audio/speech/stream", json=body | |
| ) as resp: | |
| async def audio_stream(): | |
| async for chunk in resp.aiter_bytes(): | |
| yield chunk | |
| return StreamingResponse( | |
| audio_stream(), | |
| media_type=resp.headers.get("content-type", "audio/wav"), | |
| ) | |
| except Exception as e: | |
| logger.error(f"[PregoPal] Stream TTS error: {e}") | |
| return JSONResponse({"error": str(e)}, status_code=502) | |
| async def audio_transcriptions(request: Request): | |
| """STT: speech -> text""" | |
| body = await request.json() | |
| client = web_app.state.llama_client | |
| try: | |
| resp = await client.post("/v1/audio/transcriptions", json=body) | |
| return JSONResponse(resp.json(), status_code=resp.status_code) | |
| except Exception as e: | |
| logger.error(f"[PregoPal] STT error: {e}") | |
| return JSONResponse({"error": str(e)}, status_code=502) | |
| async def embeddings(request: Request): | |
| body = await request.json() | |
| client = web_app.state.llama_client | |
| try: | |
| resp = await client.post("/v1/embeddings", json=body) | |
| return JSONResponse(resp.json(), status_code=resp.status_code) | |
| except Exception as e: | |
| logger.error(f"[PregoPal] Embeddings proxy error: {e}") | |
| return JSONResponse({"error": str(e)}, status_code=502) | |
| async def health(): | |
| try: | |
| client = web_app.state.llama_client | |
| ls_resp = await client.get("/health") | |
| ls_status = ls_resp.json() | |
| except Exception as e: | |
| ls_status = {"error": str(e)} | |
| omni_status = {} | |
| try: | |
| oc = web_app.state.llama_client | |
| omni_r = await oc.get("/health") | |
| omni_status = omni_r.json() | |
| except Exception as e: | |
| omni_status = {"error": str(e)} | |
| return { | |
| "status": "ok", | |
| "model": "MiniCPM-o-4_5", | |
| "engine": "llama.cpp-omni", | |
| "cuda": True, | |
| "vision": os.path.isfile(paths["vision"]), | |
| "audio": os.path.isfile(paths["audio"]), | |
| "tts_base_lm": os.path.isfile(paths["tts_base_lm"]), | |
| "tts_acoustic": os.path.isfile(paths["tts_acoustic"]), | |
| "token2wav_dir": os.path.isdir(paths["token2wav_dir"]), | |
| "llama_server_status": ls_status, | |
| "omni_server_status": omni_status, | |
| } | |
| async def list_models(): | |
| try: | |
| client = web_app.state.llama_client | |
| resp = await client.get("/v1/models") | |
| return JSONResponse(resp.json(), status_code=resp.status_code) | |
| except Exception: | |
| return JSONResponse({ | |
| "object": "list", | |
| "data": [{ | |
| "id": "MiniCPM-o-4_5", | |
| "object": "model", | |
| "created": 1, | |
| "owned_by": "prego-pal", | |
| }], | |
| }) | |
| # =========================================================================== | |
| # Omni Full-Duplex Voice Endpoints | |
| # Proxy/compatibility layer between frontend and llama-omni-server | |
| # =========================================================================== | |
| async def omni_init(request: Request): | |
| """ | |
| Initialize omni context on llama-omni-server. | |
| Body: { | |
| "session_id": str, | |
| "media_type": int (0=text, 1=image, 2=audio+vision), | |
| "use_tts": bool, | |
| "duplex_mode": bool, | |
| "model_dir": str (omit to use default /models/MiniCPM-o-4_5-gguf), | |
| "voice_audio": str (base64 audio for voice cloning, optional), | |
| "voice_clone_prompt": str (optional), | |
| "assistant_prompt": str (optional), | |
| } | |
| """ | |
| body = await request.json() | |
| body.setdefault("media_type", 2) | |
| body.setdefault("use_tts", True) | |
| body.setdefault("duplex_mode", True) | |
| body.setdefault("model_dir", os.path.join(MODEL_DIR, "MiniCPM-o-4_5-gguf")) | |
| body.setdefault("tts_bin_dir", body["model_dir"] + "/tts") | |
| body.setdefault("output_dir", OMNI_TTS_WAV_DIR) | |
| body.setdefault("token2wav_device", "gpu:0") | |
| oc = web_app.state.llama_client | |
| try: | |
| resp = await oc.post("/v1/stream/omni_init", json=body) | |
| return JSONResponse(resp.json(), status_code=resp.status_code) | |
| except Exception as e: | |
| logger.error(f"[Omni] Init error: {e}") | |
| return JSONResponse({"error": str(e)}, status_code=502) | |
| async def omni_prefill(request: Request): | |
| """ | |
| Send audio/image to omni for streaming prefill. | |
| Body: { | |
| "cnt": int, # chunk counter | |
| "audio": str (base64-encoded PCM 16kHz 16-bit, optional), | |
| "image": str (base64-encoded JPEG, optional), | |
| "text": str (optional, text input), | |
| "last_chunk": bool (True if this is the final chunk), | |
| } | |
| The audio/image base64 is decoded to a temp file, then proxied. | |
| """ | |
| body = await request.json() | |
| cnt = body.get("cnt", 0) | |
| temp_dir = web_app.state.omni_temp_dir | |
| # Decode audio base64 -> temp WAV file | |
| audio_path = "" | |
| audio_b64 = body.get("audio", "") | |
| if audio_b64: | |
| import base64, io, soundfile as sf, numpy as np | |
| audio_bytes = base64.b64decode(audio_b64) | |
| audio_np = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0 | |
| temp_audio = os.path.join(temp_dir, f"prefill_{cnt}.wav") | |
| sf.write(temp_audio, audio_np, 16000, format='WAV', subtype='PCM_16') | |
| audio_path = temp_audio | |
| # Decode image base64 -> temp PNG file | |
| img_path = "" | |
| img_b64 = body.get("image", "") | |
| if img_b64: | |
| import base64 | |
| img_bytes = base64.b64decode(img_b64) | |
| temp_img = os.path.join(temp_dir, f"prefill_{cnt}.png") | |
| with open(temp_img, "wb") as f: | |
| f.write(img_bytes) | |
| img_path = temp_img | |
| oc = web_app.state.llama_client | |
| cpp_req = { | |
| "audio_path_prefix": audio_path, | |
| "img_path_prefix": img_path, | |
| "cnt": cnt, | |
| } | |
| text = body.get("text", "") | |
| if text: | |
| cpp_req["text"] = text | |
| if "max_slice_nums" in body: | |
| cpp_req["max_slice_nums"] = body["max_slice_nums"] | |
| try: | |
| resp = await oc.post("/v1/stream/prefill", json=cpp_req) | |
| return JSONResponse(resp.json(), status_code=resp.status_code) | |
| except Exception as e: | |
| logger.error(f"[Omni] Prefill error: {e}") | |
| return JSONResponse({"error": str(e)}, status_code=502) | |
| async def omni_generate(request: Request): | |
| """ | |
| Start omni streaming generation (SSE). | |
| Proxies llama-omni-server /v1/stream/decode SSE. | |
| The SSE output contains {"content": str, "stop": bool, ...} | |
| where content is mixed text + audio tokens. | |
| Audio tokens are decoded to WAV by llama-omni-server internally | |
| (token2wav) and written to OMNI_TTS_WAV_DIR as wav_NNN.wav. | |
| """ | |
| body = await request.json() | |
| debug_dir = body.get("debug_dir", OMNI_TTS_WAV_DIR) | |
| stream = body.get("stream", True) | |
| round_idx = body.get("round_idx", -1) | |
| length_penalty = body.get("length_penalty", None) | |
| oc = web_app.state.llama_client | |
| cpp_req = { | |
| "debug_dir": debug_dir, | |
| "stream": stream, | |
| "round_idx": round_idx, | |
| } | |
| if length_penalty is not None: | |
| cpp_req["length_penalty"] = length_penalty | |
| async def sse_proxy(): | |
| async with httpx.AsyncClient(base_url=web_app.state.llama_base_url, timeout=600.0) as sclient: | |
| async with sclient.stream("POST", "/v1/stream/decode", json=cpp_req) as resp: | |
| async for chunk in resp.aiter_lines(): | |
| if chunk: | |
| yield chunk + "\n" | |
| return StreamingResponse(sse_proxy(), media_type="text/event-stream") | |
| async def omni_break(): | |
| """Break: re-initialize omni context (current gen is discarded). | |
| llama-omni-server has no standalone break endpoint, so we | |
| reinit via /v1/stream/omni_init which frees old context. | |
| """ | |
| oc = web_app.state.llama_client | |
| # Re-init with same defaults — this frees any active omni context | |
| init_body = { | |
| "media_type": 2, | |
| "use_tts": True, | |
| "duplex_mode": True, | |
| "model_dir": os.path.join(MODEL_DIR, "MiniCPM-o-4_5-gguf"), | |
| } | |
| try: | |
| resp = await oc.post("/v1/stream/omni_init", json=init_body) | |
| return JSONResponse({"status": "reinitialized", "response": resp.json()}, status_code=resp.status_code) | |
| except Exception as e: | |
| logger.error(f"[Omni] Break error: {e}") | |
| return JSONResponse({"error": str(e)}, status_code=502) | |
| async def omni_stop(request: Request): | |
| """Stop and free omni context by reinit with empty state.""" | |
| oc = web_app.state.llama_client | |
| init_body = { | |
| "media_type": 0, | |
| "use_tts": False, | |
| "duplex_mode": False, | |
| "model_dir": os.path.join(MODEL_DIR, "MiniCPM-o-4_5-gguf"), | |
| } | |
| try: | |
| resp = await oc.post("/v1/stream/omni_init", json=init_body) | |
| return JSONResponse({"status": "stopped", "response": resp.json()}, status_code=resp.status_code) | |
| except Exception as e: | |
| logger.error(f"[Omni] Stop error: {e}") | |
| return JSONResponse({"error": str(e)}, status_code=502) | |
| async def omni_tts_wav(round_dir: str, filename: str): | |
| """Serve TTS WAV files generated by llama-omni-server.""" | |
| import os | |
| wav_path = os.path.join(OMNI_TTS_WAV_DIR, round_dir, filename) | |
| if not os.path.isfile(wav_path): | |
| return JSONResponse({"error": f"WAV not found: {wav_path}"}, status_code=404) | |
| from fastapi.responses import FileResponse | |
| return FileResponse(wav_path, media_type="audio/wav") | |
| async def root(): | |
| return { | |
| "service": "PregoPal MiniCPM-o-4_5 Omni API", | |
| "version": "3.0.0", | |
| "model": MAIN_GGUF, | |
| "engine": "llama.cpp-omni (OpenBMB)", | |
| "endpoints": { | |
| "chat": "POST /v1/chat/completions (text+multimodal, streaming)", | |
| "tts": "POST /v1/audio/speech (text->speech)", | |
| "tts_stream": "POST /v1/audio/speech/stream (streaming TTS)", | |
| "stt": "POST /v1/audio/transcriptions (speech->text)", | |
| "embeddings": "POST /v1/embeddings", | |
| "models": "GET /v1/models", | |
| "health": "GET /health", | |
| "omni_init": "POST /v1/omni/init", | |
| "omni_prefill": "POST /v1/omni/prefill (base64 audio)", | |
| "omni_generate": "POST /v1/omni/generate (SSE)", | |
| "omni_break": "POST /v1/omni/break", | |
| "omni_stop": "POST /v1/omni/stop", | |
| "omni_tts_wav": "GET /v1/omni/tts_wav/{round_dir}/{filename}", | |
| }, | |
| } | |
| return web_app | |
| # ============================================================================ | |
| # 4. DIAGNOSE VOLUME | |
| # ============================================================================ | |
| def diagnose_volume(): | |
| """Check model file integrity in Modal Volume.""" | |
| print(f"\n{'='*60}") | |
| print(f"[Diagnose] {MODEL_SUBDIR}") | |
| print(f"{'='*60}") | |
| for root, dirs, files in os.walk(MODEL_SUBDIR): | |
| level = root.replace(MODEL_SUBDIR, "").count(os.sep) | |
| indent = " " * 2 * level | |
| print(f"{indent}{os.path.basename(root)}/") | |
| subindent = " " * 2 * (level + 1) | |
| for file in sorted(files): | |
| fpath = os.path.join(root, file) | |
| size = os.path.getsize(fpath) | |
| print(f"{subindent}{file} ({size:,} bytes = {size/1024**3:.2f} GB)") | |
| paths = get_model_paths(MODEL_SUBDIR) | |
| all_ok = True | |
| for key, path in paths.items(): | |
| if key == "token2wav_dir": | |
| ok = os.path.isdir(path) | |
| else: | |
| ok = os.path.isfile(path) | |
| status = "OK" if ok else "MISSING" | |
| if not ok: | |
| all_ok = False | |
| print(f" [{status}] {key}: {path}") | |
| if all_ok: | |
| print(f"\n[OK] All model files found! Ready to deploy.") | |
| else: | |
| print(f"\n[FAIL] Some files missing. Check uploads.") | |
| main_path = paths["main"] | |
| if os.path.isfile(main_path): | |
| with open(main_path, "rb") as f: | |
| magic = f.read(4) | |
| if magic == b"GGUF": | |
| print("[OK] Main model is valid GGUF") | |
| else: | |
| print(f"[WARN] Main model NOT valid GGUF (magic={magic.hex()})") | |
| print(f"\n{'='*60}") | |
| print(f"[Diagnose] CUDA library files in container") | |
| print(f"{'='*60}") | |
| import subprocess | |
| result = subprocess.run( | |
| "find / -name 'libcuda*' -type f,l 2>/dev/null | head -30", | |
| shell=True, capture_output=True, text=True | |
| ) | |
| cuda_files = result.stdout.strip().split("\\n") | |
| for f in cuda_files: | |
| if f: | |
| size = os.path.getsize(f) if os.path.exists(f) else 0 | |
| print(f" {f} ({size:,} bytes)") | |
| # ============================================================================ | |
| # 5. TEST INFERENCE (standalone - not via ASGI) | |
| # ============================================================================ | |
| def test_inference(): | |
| """Test llama-server text+multimodal inference on Modal T4.""" | |
| import subprocess | |
| import time | |
| import httpx | |
| print("[PregoPal] ========== TEST INFERENCE (llama-server) ==========") | |
| vision_path = os.path.join(MODEL_SUBDIR, VISION_MMPROJ) | |
| cmd = [ | |
| "/llama.cpp-omni/build/bin/llama-server", | |
| "-m", os.path.join(MODEL_SUBDIR, MAIN_GGUF), | |
| "--mmproj", vision_path, | |
| "--host", "127.0.0.1", | |
| "--port", "8081", | |
| "-ngl", "99", | |
| "-c", "4096", | |
| "--no-mmap", | |
| "--jinja", | |
| ] | |
| print("[PregoPal] Starting llama-server...") | |
| print(f"[PregoPal] cmd: {' '.join(cmd)}") | |
| server_proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, bufsize=1) | |
| # Collect stderr in a background thread | |
| stderr_lines = [] | |
| import threading, queue | |
| q = queue.Queue() | |
| def _reader(): | |
| for line in iter(server_proc.stderr.readline, ''): | |
| q.put(line.rstrip()) | |
| q.put(None) | |
| thr = threading.Thread(target=_reader, daemon=True) | |
| thr.start() | |
| base_url = "http://127.0.0.1:8081" | |
| ready = False | |
| start = time.time() | |
| last_log = time.time() | |
| for i in range(180): | |
| time.sleep(1) | |
| now = time.time() | |
| # Drain stderr from queue (non-blocking) | |
| while True: | |
| try: | |
| line = q.get_nowait() | |
| except queue.Empty: | |
| break | |
| if line is None: | |
| break | |
| print(f"[llama-server] {line}") | |
| last_log = now | |
| # Check if process died | |
| ret = server_proc.poll() | |
| if ret is not None: | |
| print(f"[PregoPal] PROCESS EXITED with code {ret}") | |
| # Drain remaining | |
| while True: | |
| try: | |
| line = q.get_nowait() | |
| except queue.Empty: | |
| break | |
| if line is None: | |
| break | |
| print(f"[llama-server] {line}") | |
| break | |
| # Only print waiting msg every 10s | |
| elapsed = now - start | |
| if elapsed - (i // 10) * 10 < 2: | |
| try: | |
| r = httpx.get(f"{base_url}/health", timeout=3.0) | |
| if r.status_code == 200: | |
| ready = True | |
| print(f"[PregoPal] llama-server ready ({elapsed:.0f}s)") | |
| break | |
| except Exception: | |
| pass | |
| if i % 10 == 0: | |
| print(f"[PregoPal] Waiting ({elapsed:.0f}s)...") | |
| if not ready: | |
| # Drain remaining stderr | |
| time.sleep(0.5) | |
| while True: | |
| try: | |
| line = q.get_nowait() | |
| except queue.Empty: | |
| break | |
| if line is None: | |
| break | |
| print(f"[llama-server] {line}") | |
| print(f"[PregoPal] Timed out ({time.time()-start:.0f}s). Check above for [llama-server] lines.") | |
| server_proc.terminate() | |
| return | |
| client = httpx.Client(base_url=base_url, timeout=120.0) | |
| try: | |
| # Test 1: Simple text (Chinese) | |
| print("\n[Test 1] Chinese...") | |
| t0 = time.time() | |
| resp = client.post("/v1/chat/completions", json={ | |
| "messages": [{"role": "user", "content": "你好,请简单介绍一下你自己"}], | |
| "max_tokens": 100, "temperature": 0.3, | |
| }) | |
| t1 = time.time() | |
| data = resp.json() | |
| content = data.get("choices", [{}])[0].get("message", {}).get("content", "") | |
| print(f"Response ({t1-t0:.1f}s): status={resp.status_code}") | |
| print(f" content: {content}") | |
| print(f" finish_reason: {data.get('choices',[{}])[0].get('finish_reason', 'N/A')}") | |
| print(f" usage: {data.get('usage', {})}") | |
| # Test 2: Simple text (English) - need more tokens + higher temp | |
| print("\n[Test 2] English...") | |
| t0 = time.time() | |
| resp = client.post("/v1/chat/completions", json={ | |
| "messages": [{"role": "user", "content": "What is the capital of France? Answer in one short sentence."}], | |
| "max_tokens": 100, "temperature": 0.5, | |
| }) | |
| t1 = time.time() | |
| data = resp.json() | |
| content = data.get("choices", [{}])[0].get("message", {}).get("content", "") | |
| print(f"Response ({t1-t0:.1f}s): status={resp.status_code}") | |
| print(f" content: {content}") | |
| print(f" finish_reason: {data.get('choices',[{}])[0].get('finish_reason', 'N/A')}") | |
| print(f" usage: {data.get('usage', {})}") | |
| # Test 3: Health | |
| print("\n[Test 3] Health...") | |
| resp = client.get("/health") | |
| # Test 3: Health | |
| print("\n[Test 3] Health...") | |
| resp = client.get("/health") | |
| info = resp.json() | |
| print(f"Health: model={info.get('model')}, cuda={info.get('cuda')}, " | |
| f"vision={info.get('vision')}, audio={info.get('audio')}") | |
| print(f"\n{'='*50}") | |
| print("[OK] All tests passed!") | |
| print(f"{'='*50}") | |
| except Exception as e: | |
| print(f"[PregoPal] Test error: {e}") | |
| raise | |
| finally: | |
| server_proc.terminate() | |
| server_proc.wait(timeout=10) | |
| # ============================================================================ | |
| # 6. LOCAL ENTRY POINT | |
| # ============================================================================ | |
| # ============================================================================ | |
| # 7. OMNI TEST (runs inside deployed Modal container) | |
| # ============================================================================ | |
| def test_omni(): | |
| """ | |
| Test llama-server built-in omni endpoints (/v1/stream/*). | |
| Launches llama-server with omni models and tests omni_init. | |
| Uses the same llama-server launch pattern as test_inference. | |
| """ | |
| import subprocess | |
| import time | |
| import httpx | |
| import json | |
| print("[PregoPal] ========== TEST OMNI (llama-server built-in endpoints) ==========") | |
| paths = get_model_paths(MODEL_SUBDIR) | |
| # Build llama-server command with omni model paths | |
| ls_bin = "/llama.cpp-omni/build/bin/llama-server" | |
| llm_cmd = [ | |
| ls_bin, | |
| "-m", paths["main"], | |
| "--mmproj", paths["vision"], | |
| "--host", "127.0.0.1", | |
| "--port", str(LLAMA_SERVER_PORT), | |
| "-ngl", "99", | |
| "-c", "8192", | |
| "--no-mmap", | |
| "--jinja", | |
| ] | |
| print(f" Starting llama-server with omni support...") | |
| ls_proc = subprocess.Popen(llm_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, bufsize=1) | |
| base_url = f"http://127.0.0.1:{LLAMA_SERVER_PORT}" | |
| # Wait for server | |
| start = time.time() | |
| ready = False | |
| for _ in range(90): | |
| time.sleep(1) | |
| try: | |
| r = httpx.get(f"{base_url}/health", timeout=3) | |
| if r.status_code == 200: | |
| ready = True | |
| print(f"[PregoPal] llama-server ready ({time.time()-start:.0f}s)") | |
| break | |
| except: | |
| pass | |
| if not ready: | |
| print(f"[FAIL] llama-server not ready after {time.time()-start:.0f}s") | |
| # Read stderr | |
| import select | |
| stderr_lines = [] | |
| for _ in range(50): | |
| line = ls_proc.stderr.readline() | |
| if not line: | |
| break | |
| stderr_lines.append(line.rstrip()) | |
| print(f"[llama-server stderr] ({len(stderr_lines)} lines):") | |
| for line in stderr_lines[-30:]: | |
| print(f" {line}") | |
| ls_proc.terminate() | |
| return | |
| # ====== Test omni_init ====== | |
| print("\n=== 1. Omni Init ===") | |
| init_body = { | |
| "media_type": 2, # audio + vision | |
| "use_tts": True, | |
| "duplex_mode": True, | |
| "model_dir": MODEL_SUBDIR, | |
| "tts_bin_dir": f"{MODEL_SUBDIR}/tts", | |
| "output_dir": OMNI_TTS_WAV_DIR, | |
| "token2wav_device": "gpu:0", | |
| } | |
| try: | |
| # llama-server has built-in /v1/stream/omni_init | |
| r = httpx.post(f"{base_url}/v1/stream/omni_init", json=init_body, timeout=120) | |
| print(f"Status: {r.status_code}") | |
| data = r.json() | |
| print(f"Body: {json.dumps(data, indent=2, ensure_ascii=False)}") | |
| if not data.get("success"): | |
| print("[FAIL] Omni Init failed") | |
| # Read stderr | |
| import select | |
| stderr_lines = [] | |
| for _ in range(30): | |
| line = ls_proc.stderr.readline() | |
| if not line: | |
| break | |
| stderr_lines.append(line.rstrip()) | |
| print(f"[llama-server stderr] ({len(stderr_lines)} lines):") | |
| for line in stderr_lines[-20:]: | |
| print(f" {line}") | |
| ls_proc.terminate() | |
| return | |
| print("\n=== [OK] Omni Init passed! ===") | |
| ls_proc.terminate() | |
| except Exception as e: | |
| print(f"[FAIL] Error: {e}") | |
| # Read stderr | |
| stderr_lines = [] | |
| for _ in range(30): | |
| line = ls_proc.stderr.readline() | |
| if not line: | |
| break | |
| stderr_lines.append(line.rstrip()) | |
| print(f"[llama-server stderr] ({len(stderr_lines)} lines):") | |
| for line in stderr_lines[-20:]: | |
| print(f" {line}") | |
| ls_proc.terminate() | |
| return | |
| if __name__ == "__main__": | |
| import sys | |
| if len(sys.argv) > 1: | |
| if sys.argv[1] == "test_inference": | |
| test_inference.local() | |
| elif sys.argv[1] == "diagnose_volume": | |
| diagnose_volume.local() | |
| elif sys.argv[1] == "test_omni": | |
| test_omni.local() | |