agharsallah Codex commited on
Commit
8a801e8
·
1 Parent(s): 42e32ed

feat: add Modal model-serving layer, one app per provider

Browse files

Serve small models as serverless, OpenAI-compatible endpoints on Modal
(vLLM behind an autoscaling web server). One app per provider keeps
deploy/scale/blast-radius isolated; one reusable serving path keeps adding
a model to a single ModelConfig.

- service.py: ModelConfig + image/command builder + register_model
- registry.py: declarative catalogue grouped by provider
- app_{nvidia,openbmb,google}.py: one Modal app each (6 models, all <=32B)
- client.py: OpenAI-SDK smoke-test client
- docs/: deploy guide + in-repo Modal docs mirror; ADR-0005

Co-Authored-By: Codex <codex@openai.com>

README.md CHANGED
@@ -47,6 +47,10 @@ docs/
47
  scripts/
48
  new_journal_entry.py Creates dated build log entries
49
  snapshot_progress.py Updates docs/blog/building-in-public.md from journal
 
 
 
 
50
  ```
51
 
52
  ## Development Loop
 
47
  scripts/
48
  new_journal_entry.py Creates dated build log entries
49
  snapshot_progress.py Updates docs/blog/building-in-public.md from journal
50
+ modal/
51
+ service.py Reusable vLLM serving layer (OpenAI-compatible)
52
+ registry.py Declarative model catalogue, grouped by provider
53
+ app_*.py One Modal app per provider (nvidia/openbmb/google)
54
  ```
55
 
56
  ## Development Loop
docs/adr/0005-modal-model-serving.md ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR-0005: Serve Small Models on Modal, One App Per Provider
2
+
3
+ ## Status
4
+
5
+ Accepted
6
+
7
+ ## Context
8
+
9
+ The engine routes agent roles to small models through an OpenAI-compatible
10
+ interface, but until now there was no hosted backend behind it — only the local
11
+ deterministic stub. We need real small models (all under the 32B cap, with a
12
+ ≤4B Tiny Titan tier) served as APIs the engine can call, without coupling the
13
+ engine to any single inference vendor.
14
+
15
+ We want the serving layer to be scalable (autoscaling, pay-per-use), extensible
16
+ (adding a model or provider should be trivial), and configurable per task (GPU,
17
+ context length, concurrency, tool/reasoning parsers, multimodal limits).
18
+
19
+ ## Decision
20
+
21
+ Add a `modal/` folder that serves models on Modal as serverless,
22
+ OpenAI-compatible endpoints (vLLM behind an autoscaling web server).
23
+
24
+ - **One Modal app per provider** (`nvidia-llms`, `openbmb-llms`, `google-llms`).
25
+ Providers deploy, scale, and fail independently.
26
+ - **One reusable serving path** in `service.py` (`ModelConfig` + `register_model`)
27
+ shared by every app, so the vLLM/Modal best practices are written once.
28
+ - **Configuration is data** in `registry.py`: a model is one `ModelConfig`; a
29
+ provider is one app file. This mirrors the project's "config, not code"
30
+ invariant (see ADR for declarative worlds).
31
+ - Weights and the vLLM compile cache live in **shared Volumes**, so a model
32
+ pulled once is warm across every provider app.
33
+
34
+ ## Consequences
35
+
36
+ - The engine talks to any endpoint via the OpenAI SDK by setting
37
+ `OPENAI_BASE_URL`; model roles (`MODEL_TINY/FAST/BALANCED/STRONG`) map to the
38
+ endpoint whose size fits the role.
39
+ - Vendor isolation: a provider can be added, retuned, or removed without
40
+ touching the others or the engine.
41
+ - Gated repos (Gemma, the Nemotron repos used here) require a Hugging Face token
42
+ in the `huggingface-secret` Modal Secret; ungated models deploy without it.
43
+ - vLLM tool/reasoning parser names are version-specific and left conservative;
44
+ enable per model once verified against the deployed vLLM version.
45
+ - Modal's docs index is mirrored at `modal/docs/modal-llms.txt` and refreshed
46
+ when the pinned vLLM/Modal versions change (ADR-0004, document-as-we-build).
modal/README.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modal model serving
2
+
3
+ Serverless, OpenAI-compatible endpoints for small models, deployed on
4
+ [Modal](https://modal.com). Each provider is an isolated Modal app; all of them
5
+ share one battle-tested serving path (vLLM behind an autoscaling web server) so
6
+ adding a model is a one-line config change.
7
+
8
+ ## Layout
9
+
10
+ ```text
11
+ modal/
12
+ service.py Reusable serving layer: ModelConfig, image + vllm command,
13
+ register_model() (provider-agnostic).
14
+ registry.py Declarative catalogue of every model, grouped by provider.
15
+ app_nvidia.py App "nvidia-llms" — Nemotron 3 Nano 30B + 4B.
16
+ app_openbmb.py App "openbmb-llms" — MiniCPM-o 4.5 + MiniCPM4.1-8B.
17
+ app_google.py App "google-llms" — Gemma 4 26B + 12B.
18
+ client.py OpenAI-SDK smoke-test client for any endpoint.
19
+ requirements.txt Deploy/client tooling (vLLM lives in the container image).
20
+ docs/
21
+ deploying.md Deploy, configure, auth, GPU sizing, engine integration.
22
+ modal-llms.txt In-repo mirror of Modal's docs index, kept updated.
23
+ ```
24
+
25
+ ## Models
26
+
27
+ | Provider | App | Model | Endpoint name | GPU |
28
+ | -------- | -------------- | --------------------------------------- | --------------------- | ------- |
29
+ | NVIDIA | `nvidia-llms` | NVIDIA-Nemotron-3-Nano-30B-A3B-BF16 | `nemotron-3-nano-30b` | H200:1 |
30
+ | NVIDIA | `nvidia-llms` | NVIDIA-Nemotron-3-Nano-4B-BF16 | `nemotron-3-nano-4b` | L4:1 |
31
+ | OpenBMB | `openbmb-llms` | MiniCPM-o-4_5 (omni) | `minicpm-o-4-5` | L40S:1 |
32
+ | OpenBMB | `openbmb-llms` | MiniCPM4.1-8B | `minicpm-4-1-8b` | L40S:1 |
33
+ | Google | `google-llms` | gemma-4-26B-A4B-it | `gemma-4-26b` | H200:1 |
34
+ | Google | `google-llms` | gemma-4-12B | `gemma-4-12b` | L40S:1 |
35
+
36
+ Every endpoint stays under the hackathon's 32B cap; `nemotron-3-nano-4b` is the
37
+ ≤4B Tiny Titan tier.
38
+
39
+ ## Quick start
40
+
41
+ ```bash
42
+ pip install -r modal/requirements.txt
43
+ modal token new
44
+ modal secret create huggingface-secret HF_TOKEN=hf_xxx # for gated repos
45
+
46
+ modal deploy modal/app_nvidia.py
47
+ python modal/client.py \
48
+ --base-url https://<workspace>--nemotron-3-nano-4b.modal.run/v1 \
49
+ --model nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16 \
50
+ --prompt "Hello from the wood."
51
+ ```
52
+
53
+ See [`docs/deploying.md`](docs/deploying.md) for configuration, auth, GPU
54
+ sizing, and how to add models/providers or wire endpoints into the engine.
55
+
56
+ ## Why this shape
57
+
58
+ - **Each provider in its own app** — independent deploy, scaling, and blast
59
+ radius; one provider's outage or redeploy never touches another.
60
+ - **Scalable** — serverless autoscaling, input concurrency, a shared weight
61
+ cache (pull once, warm everywhere), and per-model `min_containers` warm pools.
62
+ - **Extensible** — add a model = one `ModelConfig`; add a provider = one app
63
+ file. The serving path is written once in `service.py`.
64
+ - **Configurable per task** — GPU, context length, concurrency, tool/reasoning
65
+ parsers, and multimodal limits are all data in `registry.py`.
modal/app_google.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Google model-serving app (Gemma family).
2
+
3
+ Deploy: modal deploy modal/app_google.py
4
+ Serve dev: modal serve modal/app_google.py
5
+
6
+ Gemma repos are gated: create the ``huggingface-secret`` (HF_TOKEN=...) and
7
+ accept the model license on Hugging Face before deploying. Each model gets its
8
+ own OpenAI-compatible endpoint (one per model in ``GOOGLE_MODELS``).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import modal
14
+
15
+ from registry import GOOGLE_MODELS
16
+ from service import register_all
17
+
18
+ app = modal.App("google-llms")
19
+
20
+ register_all(app, GOOGLE_MODELS)
modal/app_nvidia.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NVIDIA model-serving app (Nemotron family).
2
+
3
+ Deploy: modal deploy modal/app_nvidia.py
4
+ Serve dev: modal serve modal/app_nvidia.py
5
+
6
+ Each model gets its own OpenAI-compatible endpoint at
7
+ ``https://<workspace>--<endpoint-name>.modal.run/v1`` (one per model in
8
+ ``NVIDIA_MODELS``). Add or retune models in ``registry.py``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import modal
14
+
15
+ from registry import NVIDIA_MODELS
16
+ from service import register_all
17
+
18
+ app = modal.App("nvidia-llms")
19
+
20
+ register_all(app, NVIDIA_MODELS)
modal/app_openbmb.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenBMB model-serving app (MiniCPM family).
2
+
3
+ Deploy: modal deploy modal/app_openbmb.py
4
+ Serve dev: modal serve modal/app_openbmb.py
5
+
6
+ Each model gets its own OpenAI-compatible endpoint (one per model in
7
+ ``OPENBMB_MODELS``). MiniCPM-o is omni-modal; see ``registry.py`` for the
8
+ multimodal limits and the extra media backends baked into its image.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import modal
14
+
15
+ from registry import OPENBMB_MODELS
16
+ from service import register_all
17
+
18
+ app = modal.App("openbmb-llms")
19
+
20
+ register_all(app, OPENBMB_MODELS)
modal/client.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tiny OpenAI-compatible client for smoke-testing a deployed endpoint.
2
+
3
+ Usage:
4
+ python modal/client.py \\
5
+ --base-url https://<workspace>--gemma-4-12b.modal.run/v1 \\
6
+ --model google/gemma-4-12B \\
7
+ --prompt "Describe a mossy ticket booth in the wood."
8
+
9
+ The endpoints speak the OpenAI REST API, so the official ``openai`` SDK works
10
+ unchanged — the engine can point ``OPENAI_BASE_URL`` at any of these URLs.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import os
17
+
18
+
19
+ def main() -> None:
20
+ parser = argparse.ArgumentParser(description=__doc__)
21
+ parser.add_argument("--base-url", required=True, help="endpoint URL ending in /v1")
22
+ parser.add_argument("--model", required=True, help="served model id")
23
+ parser.add_argument("--prompt", default="Say hello in one sentence.")
24
+ parser.add_argument("--max-tokens", type=int, default=256)
25
+ args = parser.parse_args()
26
+
27
+ from openai import OpenAI
28
+
29
+ # vLLM accepts any token unless an API key was configured on the server.
30
+ client = OpenAI(base_url=args.base_url, api_key=os.environ.get("MODAL_LLM_KEY", "EMPTY"))
31
+
32
+ response = client.chat.completions.create(
33
+ model=args.model,
34
+ messages=[{"role": "user", "content": args.prompt}],
35
+ max_tokens=args.max_tokens,
36
+ )
37
+ print(response.choices[0].message.content)
38
+
39
+
40
+ if __name__ == "__main__":
41
+ main()
modal/docs/deploying.md ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploying & configuring the model-serving apps
2
+
3
+ This guide covers prerequisites, deployment, configuration knobs, auth, GPU
4
+ sizing, and wiring the endpoints into the engine.
5
+
6
+ ## Prerequisites
7
+
8
+ ```bash
9
+ pip install -r modal/requirements.txt
10
+ modal token new # one-time auth with your Modal workspace
11
+ ```
12
+
13
+ Gated repos (Gemma, and the Nemotron repos here) require a Hugging Face token.
14
+ Accept each model's license on its Hugging Face page, then create the secret:
15
+
16
+ ```bash
17
+ modal secret create huggingface-secret HF_TOKEN=hf_xxx
18
+ ```
19
+
20
+ Only models with `gated=True` mount this secret; ungated models deploy without it.
21
+
22
+ ## Deploy
23
+
24
+ Each provider is its own Modal app, deployed independently:
25
+
26
+ ```bash
27
+ modal deploy modal/app_nvidia.py # Nemotron 3 Nano 30B + 4B
28
+ modal deploy modal/app_openbmb.py # MiniCPM-o 4.5 + MiniCPM4.1-8B
29
+ modal deploy modal/app_google.py # Gemma 4 26B + 12B
30
+ ```
31
+
32
+ Use `modal serve modal/app_<provider>.py` for a hot-reloading dev session.
33
+
34
+ Run these from the repo root; the script's own directory (`modal/`) is on
35
+ `sys.path`, so `from service import ...` / `from registry import ...` resolve,
36
+ and `import modal` still binds the installed SDK (the folder name does not
37
+ shadow it).
38
+
39
+ ## Endpoints
40
+
41
+ Each model becomes its own OpenAI-compatible endpoint, named after its
42
+ `endpoint_name`:
43
+
44
+ ```
45
+ https://<workspace>--<endpoint-name>.modal.run/v1
46
+ ```
47
+
48
+ Standard routes: `/v1/chat/completions`, `/v1/completions`, `/v1/models`, plus
49
+ `/docs` for the Swagger UI. Smoke-test one:
50
+
51
+ ```bash
52
+ python modal/client.py \
53
+ --base-url https://<workspace>--gemma-4-12b.modal.run/v1 \
54
+ --model google/gemma-4-12B \
55
+ --prompt "Describe a mossy ticket booth in the wood."
56
+ ```
57
+
58
+ ## Configuring models (per task)
59
+
60
+ All knobs live in `registry.py` as `ModelConfig` fields — no serving code
61
+ changes needed:
62
+
63
+ | Field | Purpose |
64
+ | ----------------------- | -------------------------------------------------------------- |
65
+ | `gpu` | Modal GPU spec, e.g. `H200:1`, `H100:2`, `L40S:1`, `L4:1`. |
66
+ | `tensor_parallel_size` | Shard across GPUs; set equal to the GPU count in `gpu`. |
67
+ | `max_model_len` | Cap context length to fit memory / tune throughput. |
68
+ | `max_concurrent_inputs` | Requests multiplexed onto one container before scaling out. |
69
+ | `scaledown_window` | Idle seconds before a container stops (cold-start vs. cost). |
70
+ | `min_containers` | Keep N warm to eliminate cold starts (always-on cost). |
71
+ | `reasoning_parser` / `tool_call_parser` / `enable_auto_tool_choice` | OpenAI tool/reasoning features. |
72
+ | `multimodal` / `mm_limits` | Image/audio/video inputs and per-prompt caps. |
73
+ | `trust_remote_code` | Required by MiniCPM / Nemotron custom modeling code. |
74
+ | `extra_vllm_args` | Raw `vllm serve` flags appended verbatim (escape hatch). |
75
+ | `extra_pip` / `env` | Extra image deps / container env (escape hatch). |
76
+
77
+ ### Add a model
78
+
79
+ Append one `ModelConfig` to the appropriate provider list in `registry.py`.
80
+
81
+ ### Add a provider
82
+
83
+ 1. Add a `<PROVIDER>_MODELS` list in `registry.py`.
84
+ 2. Create `app_<provider>.py` that builds `modal.App("<provider>-llms")` and
85
+ calls `register_all(app, <PROVIDER>_MODELS)`.
86
+
87
+ ## Auth
88
+
89
+ Modal web endpoints are public by default. To require a bearer token, either:
90
+
91
+ - Set `VLLM_API_KEY` on the container (via a `modal.Secret`) so vLLM enforces
92
+ `Authorization: Bearer <key>`; or
93
+ - Front the endpoint with Modal Proxy Auth Tokens
94
+ (see `docs/modal-llms.txt` → Proxy Auth Tokens).
95
+
96
+ ## GPU sizing cheatsheet
97
+
98
+ BF16 weights ≈ 2 bytes/param; leave headroom for the KV cache. MoE models load
99
+ all expert weights even though only a slice activates per token, so size to the
100
+ total parameter count.
101
+
102
+ | Model | Params (total / active) | Starting GPU |
103
+ | ---------------------------------- | ----------------------- | ------------ |
104
+ | Nemotron-3-Nano-30B-A3B | 30B / ~3B (MoE) | `H200:1` |
105
+ | Nemotron-3-Nano-4B | 4B (Tiny Titan) | `L4:1` |
106
+ | MiniCPM-o-4_5 (omni) | ~8B + media encoders | `L40S:1` |
107
+ | MiniCPM4.1-8B | 8B | `L40S:1` |
108
+ | Gemma-4-26B-A4B-it | 26B / ~4B (MoE) | `H200:1` |
109
+ | Gemma-4-12B | 12B | `L40S:1` |
110
+
111
+ These are starting points. If a container OOMs, lower `max_model_len`, raise the
112
+ GPU tier, or bump `tensor_parallel_size` (and the GPU count) for sharding.
113
+
114
+ ## Engine integration
115
+
116
+ Endpoints are OpenAI-compatible, so the engine talks to them through the OpenAI
117
+ SDK. Point a model role at a deployed endpoint:
118
+
119
+ ```bash
120
+ export OPENAI_BASE_URL="https://<workspace>--nemotron-3-nano-4b.modal.run/v1"
121
+ export OPENAI_API_KEY="EMPTY" # or the configured VLLM_API_KEY
122
+ export MODEL_TINY="nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
123
+ ```
124
+
125
+ Map the engine's `MODEL_TINY/FAST/BALANCED/STRONG` tiers to the endpoints whose
126
+ size fits each role.
modal/docs/modal-llms.txt ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modal llms.txt
2
+
3
+ > Modal is a platform for running Python code in the cloud with minimal
4
+ > configuration, especially for serving AI models and high-performance batch
5
+ > processing. It supports fast prototyping, serverless APIs, scheduled jobs,
6
+ > GPU inference, distributed volumes, and sandboxes.
7
+
8
+ > Local mirror of Modal's llms.txt, kept in-repo so the serving layer stays
9
+ > grounded in current Modal guidance. Refresh from https://modal.com/docs and
10
+ > re-sync when bumping the vLLM/Modal versions in ../service.py.
11
+
12
+ Important notes:
13
+
14
+ - Modal's primitives are embedded in Python and tailored for AI/GPU use cases,
15
+ but they can be used for general-purpose cloud compute.
16
+ - Modal is a serverless platform, meaning you are only billed for resources used
17
+ and can spin up containers on demand in seconds.
18
+
19
+ You can sign up for free at [https://modal.com] and get $30/month of credits.
20
+
21
+ ## Patterns this repo relies on
22
+
23
+ - OpenAI-compatible vLLM serving via `@modal.web_server(port=8000)` launching
24
+ `vllm serve` as a subprocess (see ../service.py).
25
+ - `@modal.concurrent(max_inputs=...)` to multiplex requests onto one container.
26
+ - Shared `modal.Volume`s for the Hugging Face weight cache and vLLM compile
27
+ cache, so weights are pulled once and warm across every provider app.
28
+ - `modal.Secret` (`huggingface-secret`, key `HF_TOKEN`) for gated repos.
29
+ - `scaledown_window` / `min_containers` for cold-start vs. cost trade-offs.
30
+
31
+ ## Guide
32
+
33
+ - [Introduction](https://modal.com/docs/guide)
34
+ - Custom container images
35
+ - [Defining Images](https://modal.com/docs/guide/images.md)
36
+ - [Using existing container images](https://modal.com/docs/guide/existing-images.md)
37
+ - [Fast pull from registry](https://modal.com/docs/guide/fast-pull-from-registry.md)
38
+ - GPUs and other resources
39
+ - [GPU acceleration](https://modal.com/docs/guide/gpu.md)
40
+ - [Using CUDA on Modal](https://modal.com/docs/guide/cuda.md)
41
+ - [Configuring CPU, memory, and disk](https://modal.com/docs/guide/resources.md)
42
+ - Scaling out
43
+ - [Scaling out](https://modal.com/docs/guide/scale.md)
44
+ - [Input concurrency](https://modal.com/docs/guide/concurrent-inputs.md)
45
+ - [Batch processing](https://modal.com/docs/guide/batch-processing.md)
46
+ - [Job queues](https://modal.com/docs/guide/job-queue.md)
47
+ - [Dynamic batching](https://modal.com/docs/guide/dynamic-batching.md)
48
+ - Deployment
49
+ - [Apps, Functions, and entrypoints](https://modal.com/docs/guide/apps.md)
50
+ - [Managing deployments](https://modal.com/docs/guide/managing-deployments.md)
51
+ - [Invoking deployed functions](https://modal.com/docs/guide/trigger-deployed-functions.md)
52
+ - [Continuous deployment](https://modal.com/docs/guide/continuous-deployment.md)
53
+ - Secrets and environment variables
54
+ - [Secrets](https://modal.com/docs/guide/secrets.md)
55
+ - [Environment variables](https://modal.com/docs/guide/environment_variables.md)
56
+ - Web Functions
57
+ - [Web Functions](https://modal.com/docs/guide/webhooks.md)
58
+ - [Streaming endpoints](https://modal.com/docs/guide/streaming-endpoints.md)
59
+ - [Web Function URLs](https://modal.com/docs/guide/webhook-urls.md)
60
+ - [Request timeouts](https://modal.com/docs/guide/webhook-timeouts.md)
61
+ - [Proxy Auth Tokens](https://modal.com/docs/guide/webhook-proxy-auth.md)
62
+ - Data sharing and storage
63
+ - [Volumes](https://modal.com/docs/guide/volumes.md)
64
+ - [Storing model weights](https://modal.com/docs/guide/model-weights.md)
65
+ - Performance
66
+ - [Cold start performance](https://modal.com/docs/guide/cold-start.md)
67
+ - [Memory Snapshots](https://modal.com/docs/guide/memory-snapshots.md)
68
+ - [High-performance LLM inference](https://modal.com/docs/guide/high-performance-llm-inference.md)
69
+ - Reliability and robustness
70
+ - [Failures and retries](https://modal.com/docs/guide/retries.md)
71
+ - [Timeouts](https://modal.com/docs/guide/timeouts.md)
72
+ - [GPU health](https://modal.com/docs/guide/gpu-health.md)
73
+
74
+ ## Examples
75
+
76
+ - Large language models (LLMs)
77
+ - [Deploy an OpenAI-compatible LLM service with vLLM](https://modal.com/docs/examples/llm_inference.md)
78
+ - [Maximize tokens per second in batch processing with vLLM](https://modal.com/docs/examples/vllm_throughput.md)
79
+ - [Serve an ultra-low-latency chatbot with SGLang](https://modal.com/docs/examples/sglang_low_latency.md)
80
+ - [Deploy Nemotron 3](https://modal.com/docs/examples/nemotron_inference.md)
81
+ - [Run a multimodal RAG chatbot to answer questions about PDFs](https://modal.com/docs/examples/chat_with_pdf_vision.md)
82
+
83
+ ## API Reference
84
+
85
+ - Application construction
86
+ - [`App`](https://modal.com/docs/reference/modal.App.md)
87
+ - [`App.function`](https://modal.com/docs/reference/modal.App.md)
88
+ - [`App.cls`](https://modal.com/docs/reference/modal.App.md)
89
+ - Web integrations
90
+ - [`web_server`](https://modal.com/docs/reference/modal.web_server.md)
91
+ - [`fastapi_endpoint`](https://modal.com/docs/reference/modal.fastapi_endpoint.md)
92
+ - [`asgi_app`](https://modal.com/docs/reference/modal.asgi_app.md)
93
+ - Function semantics
94
+ - [`concurrent`](https://modal.com/docs/reference/modal.concurrent.md)
95
+ - [`batched`](https://modal.com/docs/reference/modal.batched.md)
96
+ - Container configuration
97
+ - [`Image`](https://modal.com/docs/reference/modal.Image.md)
98
+ - [`Secret`](https://modal.com/docs/reference/modal.Secret.md)
99
+ - Data primitives
100
+ - [`Volume`](https://modal.com/docs/reference/modal.Volume.md)
modal/registry.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Declarative catalogue of every servable model, grouped by provider.
2
+
3
+ This is the single place to add, remove, or retune a model. Each provider app
4
+ imports only its own list, so providers stay isolated in separate Modal apps.
5
+
6
+ GPU sizing notes (starting points — tune against real memory use):
7
+ - BF16 weights ≈ 2 bytes/param. Leave headroom for the KV cache.
8
+ - MoE models (A3B / A4B) load all expert weights but only activate a slice,
9
+ so size GPU memory to the *total* parameter count, not the active count.
10
+ - Cap ``max_model_len`` to trade context length for KV-cache memory / throughput.
11
+
12
+ Parser names (``reasoning_parser`` / ``tool_call_parser``) are vLLM-version
13
+ specific. They are left conservative here; enable per model once verified
14
+ against the deployed vLLM version, otherwise vLLM rejects an unknown parser.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from service import ModelConfig
20
+
21
+ # --- NVIDIA --------------------------------------------------------------------
22
+
23
+ NVIDIA_MODELS: list[ModelConfig] = [
24
+ ModelConfig(
25
+ name="nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16",
26
+ endpoint_name="nemotron-3-nano-30b",
27
+ # 30B total params in BF16 (~60GB) though only ~3B activate per token.
28
+ gpu="H200:1",
29
+ max_model_len=32768,
30
+ trust_remote_code=True,
31
+ gated=True,
32
+ max_concurrent_inputs=64,
33
+ ),
34
+ ModelConfig(
35
+ name="nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16",
36
+ endpoint_name="nemotron-3-nano-4b",
37
+ # Tiny Titan tier (≤4B): comfortably fits a single 24GB L4.
38
+ gpu="L4:1",
39
+ max_model_len=16384,
40
+ trust_remote_code=True,
41
+ gated=True,
42
+ max_concurrent_inputs=32,
43
+ ),
44
+ ]
45
+
46
+ # --- OpenBMB (MiniCPM) ---------------------------------------------------------
47
+
48
+ OPENBMB_MODELS: list[ModelConfig] = [
49
+ ModelConfig(
50
+ name="openbmb/MiniCPM-o-4_5",
51
+ endpoint_name="minicpm-o-4-5",
52
+ # Omni-modal (text + vision + audio). Needs custom code and media backends.
53
+ gpu="L40S:1",
54
+ trust_remote_code=True,
55
+ multimodal=True,
56
+ mm_limits={"image": 4, "audio": 2, "video": 1},
57
+ # Audio/vision preprocessing backends pulled into the image.
58
+ extra_pip=("librosa", "soundfile", "timm"),
59
+ max_concurrent_inputs=16,
60
+ ),
61
+ ModelConfig(
62
+ name="openbmb/MiniCPM4.1-8B",
63
+ endpoint_name="minicpm-4-1-8b",
64
+ gpu="L40S:1",
65
+ max_model_len=32768,
66
+ trust_remote_code=True,
67
+ max_concurrent_inputs=48,
68
+ ),
69
+ ]
70
+
71
+ # --- Google (Gemma) ------------------------------------------------------------
72
+
73
+ GOOGLE_MODELS: list[ModelConfig] = [
74
+ ModelConfig(
75
+ name="google/gemma-4-26B-A4B-it",
76
+ endpoint_name="gemma-4-26b",
77
+ # MoE: ~26B total params (~4B active). Gated repo — needs an HF token.
78
+ gpu="H200:1",
79
+ max_model_len=32768,
80
+ gated=True,
81
+ reasoning_parser="gemma4",
82
+ tool_call_parser="gemma4",
83
+ enable_auto_tool_choice=True,
84
+ max_concurrent_inputs=64,
85
+ ),
86
+ ModelConfig(
87
+ name="google/gemma-4-12B",
88
+ endpoint_name="gemma-4-12b",
89
+ gpu="L40S:1",
90
+ max_model_len=32768,
91
+ gated=True,
92
+ reasoning_parser="gemma4",
93
+ tool_call_parser="gemma4",
94
+ enable_auto_tool_choice=True,
95
+ max_concurrent_inputs=48,
96
+ ),
97
+ ]
98
+
99
+ # Convenience: every model across providers (handy for tooling / docs).
100
+ ALL_MODELS: list[ModelConfig] = NVIDIA_MODELS + OPENBMB_MODELS + GOOGLE_MODELS
modal/requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # Tooling for deploying and calling the Modal serving apps.
2
+ # vLLM and CUDA live inside the container image (see service.py), not here.
3
+ modal>=1.0
4
+ openai>=1.40 # only needed for client.py / engine integration
modal/service.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reusable, OpenAI-compatible model-serving layer for Modal.
2
+
3
+ This module is provider-agnostic. It knows how to take a single ``ModelConfig``
4
+ and turn it into a serverless, autoscaling, OpenAI-compatible HTTP endpoint
5
+ backed by vLLM. Each provider app (``app_nvidia.py``, ``app_openbmb.py``,
6
+ ``app_google.py``) imports :func:`register_model` and wires up its own models,
7
+ so providers stay fully isolated in their own Modal apps while sharing one
8
+ battle-tested serving path.
9
+
10
+ Design goals:
11
+ - **Extensible**: add a model by appending one ``ModelConfig`` to the registry.
12
+ - **Scalable**: serverless autoscaling, input concurrency, shared weight cache.
13
+ - **Configurable per task**: every knob (GPU, context length, parsers,
14
+ multimodal limits, extra flags) lives in data, not code.
15
+
16
+ The served endpoints speak the OpenAI REST API (``/v1/chat/completions`,
17
+ ``/v1/completions``, ``/v1/models``), so any OpenAI-compatible client can call
18
+ them by pointing ``base_url`` at the deployed URL.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from dataclasses import dataclass, field
25
+
26
+ import modal
27
+
28
+ # --- Shared serving constants --------------------------------------------------
29
+
30
+ # Pin the inference stack so deploys are reproducible. Bump deliberately.
31
+ VLLM_VERSION = "0.21.0"
32
+ CUDA_IMAGE = "nvidia/cuda:12.9.0-devel-ubuntu22.04"
33
+ PYTHON_VERSION = "3.12"
34
+
35
+ # The in-container port vLLM listens on; Modal maps it to a public HTTPS URL.
36
+ VLLM_PORT = 8000
37
+
38
+ # Cache paths inside the container, backed by shared Volumes (see below).
39
+ HF_CACHE_PATH = "/root/.cache/huggingface"
40
+ VLLM_CACHE_PATH = "/root/.cache/vllm"
41
+
42
+ # Name of the Modal Secret that holds a Hugging Face token (key: HF_TOKEN).
43
+ # Required only for gated repos (e.g. Gemma). Create it once with:
44
+ # modal secret create huggingface-secret HF_TOKEN=hf_...
45
+ HF_SECRET_NAME = "huggingface-secret"
46
+
47
+ # Weights and the vLLM compile cache are shared across every provider app, so a
48
+ # model pulled once is warm for all subsequent deploys and containers.
49
+ hf_cache_vol = modal.Volume.from_name("huggingface-cache", create_if_missing=True)
50
+ vllm_cache_vol = modal.Volume.from_name("vllm-cache", create_if_missing=True)
51
+
52
+ # Baseline image shared by every text model. Multimodal models extend it via
53
+ # ``ModelConfig.extra_pip`` (see ``build_image``).
54
+ _BASE_ENV = {
55
+ "HF_HUB_CACHE": HF_CACHE_PATH,
56
+ "HF_XET_HIGH_PERFORMANCE": "1", # faster weight downloads
57
+ "VLLM_LOG_STATS_INTERVAL": "1",
58
+ }
59
+
60
+
61
+ # --- Model configuration -------------------------------------------------------
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class ModelConfig:
66
+ """Everything needed to serve one model as an OpenAI-compatible endpoint.
67
+
68
+ Add a new model by constructing one of these in ``registry.py``. Nothing
69
+ else needs to change.
70
+ """
71
+
72
+ # Identity
73
+ name: str # Hugging Face repo id, e.g. "google/gemma-4-12B"
74
+ endpoint_name: str # Modal function + URL slug, e.g. "gemma-4-12b"
75
+ served_model_name: str | None = None # model id clients pass; defaults to `name`
76
+ revision: str | None = None # pin a commit for reproducibility
77
+
78
+ # Hardware
79
+ gpu: str = "L40S:1" # Modal GPU spec, e.g. "H200:1", "H100:2", "L4:1"
80
+ tensor_parallel_size: int = 1 # set to GPU count for multi-GPU sharding
81
+
82
+ # Inference shape
83
+ max_model_len: int | None = None # cap context to fit memory / task
84
+ trust_remote_code: bool = False # required by MiniCPM / Nemotron custom code
85
+
86
+ # OpenAI feature parsers (vLLM names; leave None if unsupported on the model)
87
+ reasoning_parser: str | None = None
88
+ tool_call_parser: str | None = None
89
+ enable_auto_tool_choice: bool = False
90
+
91
+ # Multimodal
92
+ multimodal: bool = False
93
+ mm_limits: dict[str, int] | None = None # e.g. {"image": 4, "audio": 2}
94
+
95
+ # Scaling / lifecycle
96
+ max_concurrent_inputs: int = 64 # requests multiplexed onto one container
97
+ scaledown_window: int = 15 * 60 # idle seconds before a container stops
98
+ min_containers: int = 0 # keep N warm to remove cold starts (costs $)
99
+ startup_timeout: int = 30 * 60 # weight download + load can be slow
100
+ request_timeout: int = 30 * 60 # max seconds a single request may run
101
+
102
+ # Access
103
+ gated: bool = False # repo needs a Hugging Face token
104
+
105
+ # Escape hatches
106
+ extra_vllm_args: tuple[str, ...] = () # raw flags appended verbatim
107
+ env: dict[str, str] = field(default_factory=dict) # extra container env
108
+ extra_pip: tuple[str, ...] = () # extra deps (audio/vision backends, etc.)
109
+
110
+ @property
111
+ def served_name(self) -> str:
112
+ return self.served_model_name or self.name
113
+
114
+
115
+ # --- Image + command construction ----------------------------------------------
116
+
117
+
118
+ def build_image(cfg: ModelConfig) -> modal.Image:
119
+ """Build the container image for a model. Layers are cached and shared, so
120
+ text models that only differ in env reuse the same base layers."""
121
+ image = (
122
+ modal.Image.from_registry(CUDA_IMAGE, add_python=PYTHON_VERSION)
123
+ .entrypoint([]) # drop the CUDA image's default entrypoint
124
+ .uv_pip_install(f"vllm=={VLLM_VERSION}")
125
+ .env(_BASE_ENV)
126
+ )
127
+ if cfg.extra_pip:
128
+ image = image.uv_pip_install(*cfg.extra_pip)
129
+ if cfg.env:
130
+ image = image.env(cfg.env)
131
+ return image
132
+
133
+
134
+ def build_command(cfg: ModelConfig) -> list[str]:
135
+ """Assemble the ``vllm serve`` argv for a model. Returned as a list so we can
136
+ launch with ``subprocess.Popen`` without a shell (no quoting pitfalls)."""
137
+ cmd: list[str] = [
138
+ "vllm",
139
+ "serve",
140
+ cfg.name,
141
+ "--host",
142
+ "0.0.0.0",
143
+ "--port",
144
+ str(VLLM_PORT),
145
+ "--served-model-name",
146
+ cfg.served_name,
147
+ "--tensor-parallel-size",
148
+ str(cfg.tensor_parallel_size),
149
+ "--uvicorn-log-level",
150
+ "info",
151
+ ]
152
+ if cfg.revision:
153
+ cmd += ["--revision", cfg.revision]
154
+ if cfg.max_model_len:
155
+ cmd += ["--max-model-len", str(cfg.max_model_len)]
156
+ if cfg.trust_remote_code:
157
+ cmd += ["--trust-remote-code"]
158
+ if cfg.reasoning_parser:
159
+ cmd += ["--reasoning-parser", cfg.reasoning_parser]
160
+ if cfg.enable_auto_tool_choice:
161
+ cmd += ["--enable-auto-tool-choice"]
162
+ if cfg.tool_call_parser:
163
+ cmd += ["--tool-call-parser", cfg.tool_call_parser]
164
+ if cfg.mm_limits:
165
+ cmd += ["--limit-mm-per-prompt", json.dumps(cfg.mm_limits)]
166
+ cmd += list(cfg.extra_vllm_args)
167
+ return cmd
168
+
169
+
170
+ # --- Endpoint registration ------------------------------------------------------
171
+
172
+
173
+ def register_model(app: modal.App, cfg: ModelConfig) -> modal.Function:
174
+ """Attach one model to ``app`` as an autoscaling, OpenAI-compatible endpoint.
175
+
176
+ The function is serialized (its prebuilt ``vllm serve`` argv is shipped to
177
+ the container), which lets us register many distinctly-named endpoints from
178
+ a simple loop without each needing a hand-written module-level function.
179
+ """
180
+ image = build_image(cfg)
181
+ cmd = build_command(cfg)
182
+ secrets = [modal.Secret.from_name(HF_SECRET_NAME)] if cfg.gated else []
183
+
184
+ @app.function(
185
+ name=cfg.endpoint_name,
186
+ image=image,
187
+ gpu=cfg.gpu,
188
+ volumes={HF_CACHE_PATH: hf_cache_vol, VLLM_CACHE_PATH: vllm_cache_vol},
189
+ secrets=secrets,
190
+ scaledown_window=cfg.scaledown_window,
191
+ min_containers=cfg.min_containers,
192
+ timeout=cfg.request_timeout,
193
+ serialized=True,
194
+ )
195
+ @modal.concurrent(max_inputs=cfg.max_concurrent_inputs)
196
+ @modal.web_server(port=VLLM_PORT, startup_timeout=cfg.startup_timeout)
197
+ def serve():
198
+ import subprocess
199
+
200
+ # vLLM serves the OpenAI REST API on VLLM_PORT; Modal exposes it publicly.
201
+ subprocess.Popen(cmd)
202
+
203
+ return serve
204
+
205
+
206
+ def register_all(app: modal.App, configs: list[ModelConfig]) -> None:
207
+ """Register every model in ``configs`` onto ``app``."""
208
+ for cfg in configs:
209
+ register_model(app, cfg)