agharsallah Codex commited on
Commit ·
8a801e8
1
Parent(s): 42e32ed
feat: add Modal model-serving layer, one app per provider
Browse filesServe 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 +4 -0
- docs/adr/0005-modal-model-serving.md +46 -0
- modal/README.md +65 -0
- modal/app_google.py +20 -0
- modal/app_nvidia.py +20 -0
- modal/app_openbmb.py +20 -0
- modal/client.py +41 -0
- modal/docs/deploying.md +126 -0
- modal/docs/modal-llms.txt +100 -0
- modal/registry.py +100 -0
- modal/requirements.txt +4 -0
- modal/service.py +209 -0
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)
|