| """ |
| modal_app.py β serves NVIDIA Nemotron-3-Nano-30B-A3B via llama.cpp's `llama-server` |
| on a Modal GPU, exposing an OpenAI-compatible endpoint. |
| |
| This is an OPTIONAL dev/demo backend for machines without the RAM to run Nemotron |
| locally. The CANONICAL run path is fully local (e.g. a Mac with 32 GB+ running Nemotron |
| via a local llama-server) β that path is π Off-the-Grid (all inference on-device; only |
| one-time model downloads touch the network) AND π¦ Llama Champion (llama.cpp runtime). |
| The Gradio app / agents never import modal β they just point `LLAMACPP_BASE_URL` at a |
| llama.cpp endpoint and set `LLAMACPP_API_KEY`. Pointing it at THIS Modal URL is the one |
| choice that trades away Off-the-Grid (it's a cloud call); it keeps Llama Champion either |
| way. Local embedding (MiniCPM via sentence-transformers) + LanceDB stay off-grid regardless. |
| |
| Built on Modal's official LLM-serving pattern (https://modal.com/docs/examples/llm_inference |
| and /vllm_inference): the same `@app.function` β `@modal.concurrent` β `@modal.web_server` |
| stack that works for vLLM works for llama-server (confirmed by the Modal team). We just |
| launch `llama-server` as the subprocess instead of `vllm serve`. |
| |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| DEPLOY (run these yourself; the venv has modal + you're authenticated as soumyaray532): |
| |
| 1. Create the API-key secret ONCE (pick any long private value): |
| modal secret create adpd-llama LLAMA_API_KEY=sk-pick-something-long |
| |
| 2. Smoke-test end-to-end on an ephemeral container (cold start downloads ~23 GB once): |
| modal run modal_app.py |
| |
| 3. Deploy the persistent endpoint: |
| modal deploy modal_app.py |
| |
| 4. Modal prints a URL like: https://<workspace>--adpd-llama-serve.modal.run |
| Put these in your local .env (and later in the Space secrets): |
| LLAMACPP_BASE_URL = https://<workspace>--adpd-llama-serve.modal.run/v1 |
| LLAMACPP_API_KEY = <the LLAMA_API_KEY you chose> |
| LLM_MODEL_ID = unsloth/Nemotron-3-Nano-30B-A3B # must match --alias below |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| VERIFIED 2026-06-06 (don't trust memory β these were checked against the live repo/docs): |
| - GGUF tag `UD-Q4_K_XL` exists as a SINGLE (un-split) file in |
| unsloth/Nemotron-3-Nano-30B-A3B-GGUF, size β 22.8 GB. `-hf repo:QUANT` downloads it. |
| - Unsloth's recommended llama-server flags: --temp 0.6 --top-p 0.95 --min-p 0.01 |
| --ctx-size 16384 (https://unsloth.ai/docs/models/nemotron-3). |
| - Nemotron 3 is a HYBRID MAMBA-2 model β needs a RECENT llama.cpp build. The rolling |
| `:server-cuda` image tag (pulled fresh at Modal image-build time) is current and runs |
| it. If you ever hit `mamba-base.cpp ... GGML_ASSERT` (ggml-org/llama.cpp#20570), |
| the image is stale β force a rebuild or pin a newer image tag. |
| - UPGRADE PATH (parked): bartowski/nvidia_Nemotron-Cascade-2-30B-A3B-GGUF:Q5_K_M |
| (β26.2 GB) is a near drop-in with much stronger codegen (LiveCodeBench 68β87). Swap |
| MODEL_REPO/MODEL_QUANT/MODEL_ALIAS + sampling temp=1.0/top_p=0.95 and bump -c to 32768. |
| |
| GPU SIZING (the bug in the previous version): the quant is 22.8 GB and 4-bit needs |
| ~24 GB, so it does NOT safely fit a 24 GB L4 once you add the KV cache + CUDA compute |
| buffers β OOM at load. We use an L40S (48 GB) for comfortable headroom and full GPU |
| offload. The Mamba-2 state is constant-size, so large context stays cheap. To cut cost |
| you can try gpu="A100-40GB", but do NOT drop back to "L4" with this quant. |
| """ |
|
|
| import modal |
|
|
| MIN = 60 |
|
|
| |
| MODEL_REPO = "unsloth/Nemotron-3-Nano-30B-A3B-GGUF" |
| MODEL_QUANT = "Q8_0" |
| MODEL_ALIAS = "unsloth/Nemotron-3-Nano-30B-A3B" |
| PORT = 8080 |
| GPU = "L40S" |
|
|
| |
| |
| LLAMA_SERVER_CANDIDATES = ("/app/llama-server", "/llama-server", "/usr/bin/llama-server") |
|
|
| app = modal.App("adpd-llama") |
|
|
| |
| |
| llama_image = ( |
| modal.Image.from_registry( |
| "ghcr.io/ggml-org/llama.cpp:server-cuda", add_python="3.12" |
| ) |
| .entrypoint([]) |
| .env({"LLAMA_CACHE": "/cache"}) |
| ) |
|
|
| |
| cache_vol = modal.Volume.from_name("adpd-llama-cache", create_if_missing=True) |
|
|
|
|
| @app.function( |
| image=llama_image, |
| gpu=GPU, |
| volumes={"/cache": cache_vol}, |
| secrets=[modal.Secret.from_name("adpd-llama")], |
| timeout=15 * MIN, |
| scaledown_window=5 * MIN, |
| max_containers=1, |
| |
| ) |
| @modal.concurrent(max_inputs=10) |
| @modal.web_server(port=PORT, startup_timeout=20 * MIN) |
| def serve(): |
| import os |
| import shutil |
| import subprocess |
|
|
| bin_path = next( |
| (p for p in (*LLAMA_SERVER_CANDIDATES, shutil.which("llama-server")) |
| if p and os.path.exists(p)), |
| None, |
| ) |
| if not bin_path: |
| raise RuntimeError("llama-server binary not found (checked /app, /, /usr/bin, PATH)") |
|
|
| api_key = os.environ["LLAMA_API_KEY"] |
| cmd = [ |
| bin_path, |
| "-hf", f"{MODEL_REPO}:{MODEL_QUANT}", |
| "--alias", MODEL_ALIAS, |
| "--host", "0.0.0.0", |
| "--port", str(PORT), |
| "--api-key", api_key, |
| "--jinja", |
| "--reasoning-budget", "0", |
| |
| |
| "-ngl", "99", |
| |
| |
| |
| |
| |
| |
| "-c", "65536", |
| |
| "--temp", "0.6", |
| "--top-p", "0.95", |
| "--min-p", "0.01", |
| ] |
| printable = " ".join("***" if a == api_key else a for a in cmd) |
| print("launching llama-server:", printable) |
| subprocess.Popen(cmd) |
|
|
|
|
| @app.local_entrypoint() |
| def test(): |
| """`modal run modal_app.py` β cold-start the server and hit the OpenAI endpoint once. |
| |
| Reads the API key from LLAMACPP_API_KEY (or LLAMA_API_KEY) in your local env/.env so |
| it can authenticate against the running server. Polls /v1/models until the (possibly |
| ~20 min first-time) download+load finishes, then sends one chat completion. |
| """ |
| import json |
| import os |
| import time |
| import urllib.error |
| import urllib.request |
|
|
| base = serve.get_web_url() |
| api_key = os.environ.get("LLAMACPP_API_KEY") or os.environ.get("LLAMA_API_KEY") |
| if not api_key: |
| print("β οΈ No LLAMACPP_API_KEY / LLAMA_API_KEY in your local env β the server " |
| "requires --api-key, so requests will 401. Set it (same value you gave the " |
| "`adpd-llama` secret) and re-run.") |
| headers = {"Authorization": f"Bearer {api_key or ''}", "Content-Type": "application/json"} |
|
|
| def get(path): |
| req = urllib.request.Request(base + path, headers=headers) |
| with urllib.request.urlopen(req, timeout=30) as r: |
| return r.status, r.read() |
|
|
| |
| print(f"waiting for server at {base} (first cold start can take ~20 min) ...") |
| deadline = 22 * MIN |
| waited = 0 |
| while True: |
| try: |
| status, _ = get("/v1/models") |
| if status == 200: |
| break |
| except urllib.error.HTTPError as e: |
| if e.code == 401: |
| print("server is up but rejected the key (401). Fix the API key and re-run.") |
| return |
| except Exception: |
| pass |
| if waited >= deadline: |
| print("timed out waiting for the server to become ready.") |
| return |
| time.sleep(15) |
| waited += 15 |
|
|
| print("server ready β sending a test chat completion ...") |
| payload = json.dumps({ |
| "model": MODEL_ALIAS, |
| "messages": [{"role": "user", "content": "Say hello in exactly three words."}], |
| "temperature": 0.6, |
| "max_tokens": 256, |
| }).encode() |
|
|
| |
| |
| |
| for attempt in range(1, 41): |
| try: |
| req = urllib.request.Request(base + "/v1/chat/completions", data=payload, |
| headers=headers, method="POST") |
| with urllib.request.urlopen(req, timeout=120) as r: |
| body = json.loads(r.read()) |
| print("reply:", body["choices"][0]["message"]["content"]) |
| return |
| except urllib.error.HTTPError as e: |
| if e.code == 503: |
| print(f" 503 (model still loading) β retry {attempt}/40 in 15s ...") |
| time.sleep(15) |
| continue |
| print(f"chat completion failed: HTTP {e.code} β {e.read().decode(errors='ignore')}") |
| return |
| except Exception as e: |
| print(f" request error ({e!r}) β retry {attempt}/40 in 15s ...") |
| time.sleep(15) |
| continue |
| print("gave up after 40 retries β server kept returning 503 / errors.") |
|
|