| """Dukaan Saathi GPU services on Modal, SPLIT across two GPUs so neither starves: |
| |
| serve (L4) -> the LLM + vision/OCR (llama.cpp llama-server) |
| GET /health |
| * /v1/... -> OpenAI Chat Completions (+ vision via image_url) |
| serve_speech (T4) -> STT (faster-whisper) + TTS (Veena) |
| GET /health |
| POST /stt {sr, audio:[...]} -> {"text","language","confidence","no_speech","ok","reason"} |
| POST /tts {"text": "..."} -> WAV bytes (audio/wav), header x-sample-rate |
| |
| Why split: a single L4 (24GB) cannot hold Gemma-12B (`-ngl 99`) + 2x Whisper + Veena |
| on-GPU at once. Veena gets VRAM-starved — `device_map="auto"` CPU-offloads it (~3 tok/s, |
| truncated audio) and forcing it onto the GPU OOMs into a *silent* 46-byte WAV. A dedicated |
| **T4 (16GB)** for speech (Whisper ~4.5GB + Veena 4-bit ~3GB) runs both fully on-GPU, fast + |
| complete, while the LLM keeps the L4 to itself. |
| |
| Deploy (one app, two web functions -> two URLs): |
| MODAL_PROFILE=projects-ps MIN_CONTAINERS=1 PYTHONPATH="$PWD" modal deploy scripts/modal_app.py |
| # LLM: https://<ws>--dukaan-llm-serve.modal.run/v1 |
| # STT/TTS: https://<ws>--dukaan-llm-serve-speech.modal.run/{stt,tts} |
| Point the Space at BOTH (Settings -> Variables/secrets): |
| DUKAAN_LLM_BASE_URL = https://<ws>--dukaan-llm-serve.modal.run/v1 |
| DUKAAN_STT_BASE_URL = https://<ws>--dukaan-llm-serve-speech.modal.run/stt |
| DUKAAN_TTS_BASE_URL = https://<ws>--dukaan-llm-serve-speech.modal.run/tts |
| |
| Veena is GATED -> the speech container needs an HF token (Modal secret "huggingface"). |
| """ |
| import os |
| import subprocess |
|
|
| import modal |
|
|
| MIN = 60 |
| LLM_GPU = os.environ.get("MODAL_GPU", "L4") |
| SPEECH_GPU = os.environ.get("MODAL_SPEECH_GPU", "L4") |
| |
| |
| MIN_CONTAINERS = int(os.environ.get("MIN_CONTAINERS", "1")) |
| CACHE = "/cache" |
| LLM_REPO = os.environ.get("LLM_REPO", "ggml-org/gemma-4-12B-it-GGUF") |
| LLM_FILE = os.environ.get("LLM_FILE", "gemma-4-12B-it-Q4_K_M.gguf") |
| LLM_MMPROJ = os.environ.get("LLM_MMPROJ", "mmproj-gemma-4-12B-it-Q8_0.gguf") |
| LLM_CTX = os.environ.get("LLM_CTX", "16384") |
|
|
| app = modal.App("dukaan-llm") |
|
|
| image = ( |
| modal.Image.from_registry("ghcr.io/ggml-org/llama.cpp:server-cuda", add_python="3.12") |
| .pip_install( |
| "fastapi", "httpx", "huggingface_hub[hf_transfer]", |
| "faster-whisper", "transformers>=5.10.1", "torch", "snac", |
| "soundfile", "scipy", "numpy<2.2", "uroman", |
| ) |
| .pip_install("bitsandbytes", "accelerate") |
| .env({ |
| "HF_HUB_ENABLE_HF_TRANSFER": "1", |
| "DUKAAN_WHISPER_DEVICE": "cuda", |
| "DUKAAN_TTS_DEVICE": "cuda", |
| |
| |
| |
| "DUKAAN_VEENA_4BIT": "false", |
| "DUKAAN_DATA_DIR": "/tmp/dukaan-data", |
| }) |
| .entrypoint([]) |
| .add_local_python_source("dukaan") |
| ) |
| hf_cache = modal.Volume.from_name("dukaan-hf-cache", create_if_missing=True) |
|
|
|
|
| |
| @app.function( |
| image=image, |
| gpu=LLM_GPU, |
| volumes={CACHE: hf_cache}, |
| timeout=24 * MIN * MIN, |
| min_containers=MIN_CONTAINERS, |
| scaledown_window=10 * MIN, |
| secrets=[modal.Secret.from_name("huggingface")], |
| ) |
| @modal.concurrent(max_inputs=24) |
| @modal.asgi_app() |
| def serve(): |
| """Gemma LLM + vision/OCR via the llama.cpp llama-server on the L4.""" |
| import httpx |
| from fastapi import FastAPI, Request |
| from fastapi.responses import JSONResponse, Response |
| from huggingface_hub import hf_hub_download |
|
|
| gguf = hf_hub_download(LLM_REPO, LLM_FILE, local_dir=CACHE) |
| mmproj = hf_hub_download(LLM_REPO, LLM_MMPROJ, local_dir=CACHE) |
| hf_cache.commit() |
|
|
| import shutil |
| binpath = shutil.which("llama-server") |
| if not binpath: |
| for cand in ("/app/llama-server", "/usr/local/bin/llama-server", |
| "/usr/bin/llama-server", "/llama-server"): |
| if os.path.exists(cand): |
| binpath = cand |
| break |
| binpath = binpath or "llama-server" |
| bindir = os.path.dirname(binpath) or "/app" |
| env = dict(os.environ) |
| env["LD_LIBRARY_PATH"] = bindir + ":" + env.get("LD_LIBRARY_PATH", "") |
| print(f"[modal_app] starting llama-server: {binpath} (LD_LIBRARY_PATH+={bindir})", flush=True) |
| subprocess.Popen( |
| [binpath, "-m", gguf, "--mmproj", mmproj, "--host", "127.0.0.1", |
| "--port", "8080", "-c", LLM_CTX, "-ngl", "99", "--jinja"], |
| env=env, |
| ) |
|
|
| web = FastAPI() |
| llm = httpx.AsyncClient(base_url="http://127.0.0.1:8080", timeout=300.0) |
|
|
| @web.get("/health") |
| async def health(): |
| try: |
| r = await llm.get("/health") |
| return JSONResponse({"ok": True, "llm": r.status_code == 200}) |
| except Exception: |
| return JSONResponse({"ok": True, "llm": False}) |
|
|
| @web.api_route("/v1/{path:path}", methods=["GET", "POST"]) |
| async def proxy(path: str, request: Request): |
| body = await request.body() |
| r = await llm.request( |
| request.method, f"/v1/{path}", content=body, |
| headers={"content-type": request.headers.get("content-type", "application/json")}, |
| ) |
| return Response(content=r.content, status_code=r.status_code, |
| media_type=r.headers.get("content-type", "application/json")) |
|
|
| return web |
|
|
|
|
| |
| @app.function( |
| image=image, |
| gpu=SPEECH_GPU, |
| timeout=24 * MIN * MIN, |
| min_containers=MIN_CONTAINERS, |
| scaledown_window=10 * MIN, |
| secrets=[modal.Secret.from_name("huggingface")], |
| ) |
| @modal.concurrent(max_inputs=8) |
| @modal.asgi_app() |
| def serve_speech(): |
| """faster-whisper STT + Veena TTS on a dedicated T4 — no llama-server, so both |
| models run fully on-GPU (fast + complete) without competing with the LLM.""" |
| import io |
| import threading |
|
|
| import numpy as np |
| import soundfile as sf |
| from fastapi import FastAPI, Request |
| from fastapi.responses import JSONResponse, Response |
|
|
| |
| |
| def _warm(): |
| try: |
| from dukaan import stt as _stt, tts as _tts |
| _stt.warmup() |
| _tts.warmup() |
| print("[modal_app] STT+TTS pre-loaded on the speech GPU", flush=True) |
| except Exception as e: |
| print(f"[modal_app] speech warmup failed: {e}", flush=True) |
|
|
| threading.Thread(target=_warm, daemon=True).start() |
|
|
| web = FastAPI() |
|
|
| @web.get("/health") |
| async def health(): |
| return JSONResponse({"ok": True, "speech": True}) |
|
|
| @web.post("/stt") |
| async def stt(request: Request): |
| from dukaan import stt as _stt |
| p = await request.json() |
| arr = np.asarray(p.get("audio", []), dtype=np.float32) |
| res = _stt.transcribe((int(p.get("sr", 16000)), arr), language=p.get("language")) |
| return JSONResponse({ |
| "text": res.text, "language": res.language, "confidence": res.confidence, |
| "no_speech": res.no_speech, "ok": res.ok, "reason": res.reason, |
| }) |
|
|
| @web.post("/tts") |
| async def tts(request: Request): |
| from dukaan import tts as _tts |
| p = await request.json() |
| sr, wav = _tts.synthesize(p.get("text", "")) |
| buf = io.BytesIO() |
| sf.write(buf, np.asarray(wav, dtype=np.float32), int(sr), format="WAV") |
| buf.seek(0) |
| return Response(content=buf.read(), media_type="audio/wav", |
| headers={"x-sample-rate": str(int(sr))}) |
|
|
| return web |
|
|