agharsallah
feat(commentary): refine rafters-critic persona and improve commentary prompt for humor
26bc5b9 | """The single source of truth for every servable model — provider-agnostic data. | |
| This module is **stdlib-only**: it does *not* ``import modal`` and does not touch | |
| the serving path. That is deliberate. Two very different consumers read it: | |
| * the **serving side** (``service.py`` + ``app_<provider>.py``) turns each | |
| :class:`ModelConfig` into an autoscaling, OpenAI-compatible vLLM endpoint on | |
| Modal; and | |
| * the **engine** (``src/models/modal_catalogue.py``) reads the same catalogue to | |
| learn which models exist and how to *call* them — deriving each profile's | |
| LiteLLM model string and endpoint URL from the data here, so a model added in | |
| one place is immediately usable by the cast. | |
| Because the engine cannot ``import modal`` (the folder name would shadow the PyPI | |
| SDK), it loads this file *by path*. Keeping the catalogue free of any Modal/vLLM | |
| import is what makes that load cheap, offline-safe, and dependency-free — so | |
| nothing here may grow a heavy import. | |
| Add a model = append one :class:`ModelConfig` to a provider list below. Add a | |
| provider = add one :class:`Provider`. Everything downstream (the deployed | |
| endpoint, the URL the engine calls, the docs table) derives from this data. | |
| GPU sizing notes (starting points — tune against real memory use): | |
| - BF16 weights ≈ 2 bytes/param. Leave headroom for the KV cache. | |
| - MoE models (A3B / A4B) load all expert weights but only activate a slice, | |
| so size GPU memory to the *total* parameter count, not the active count. | |
| - Cap ``max_model_len`` to trade context length for KV-cache memory / throughput. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass, field | |
| # --- Model configuration ------------------------------------------------------- | |
| class ModelConfig: | |
| """Everything needed to serve one model as an OpenAI-compatible endpoint. | |
| Construct one of these in a provider list below. The serving layer | |
| (``service.py``) reads the hardware/inference/scaling fields; the engine | |
| reads ``endpoint_name`` / ``served_name`` / ``profile`` / ``params_b`` to call | |
| it. Nothing else needs to change to add a model. | |
| """ | |
| # Identity | |
| name: str # Hugging Face repo id, e.g. "google/gemma-4-12B" | |
| endpoint_name: str # Modal function + URL slug, e.g. "gemma-4-12b"; also the engine casting key | |
| served_model_name: str | None = None # model id clients pass; defaults to `name` | |
| revision: str | None = None # pin a commit for reproducibility | |
| # Logical role (engine-facing). The tier this model is the default casting for | |
| # (tiny ≤4B / fast ≤7B / balanced ≤13B / strong ≤32B), or None for an | |
| # alternate/specialist model not bound to a profile by default. | |
| profile: str | None = None | |
| params_b: float | None = None # total parameter count in billions (docs / Tiny-Titan checks) | |
| # Hardware | |
| gpu: str = "L40S:1" # Modal GPU spec, e.g. "H200:1", "H100:2", "L4:1" | |
| tensor_parallel_size: int = 1 # set to GPU count for multi-GPU sharding | |
| # Inference-stack override (escape hatch). ``None`` uses the serving layer's | |
| # pinned ``VLLM_VERSION`` (the reproducible default). ``"nightly"`` installs the | |
| # latest vLLM nightly wheel; any other string is a pinned version (e.g. | |
| # ``"0.23.0"``). Use only when a model needs a build the default pin can't serve | |
| # — e.g. Gemma 4's ``gemma4_unified`` arch, unservable on 0.21.0. Scoped per | |
| # model, so one model's bump never touches another provider's app. | |
| vllm_version: str | None = None | |
| # Inference shape | |
| max_model_len: int | None = None # cap context to fit memory / task | |
| trust_remote_code: bool = False # required by MiniCPM / Nemotron custom code | |
| # Performance / throughput (vLLM serve flags). Defaults target high | |
| # steady-state throughput on the common single-GPU path; tune per model. | |
| # See ``service.build_command`` for how each maps to a flag. For anything more | |
| # exotic (quantization, batch-size caps, …) use ``extra_vllm_args``. | |
| gpu_memory_utilization: float | None = None # fraction of VRAM for weights + KV cache (vLLM default 0.9) | |
| enable_prefix_caching: bool = True # reuse KV for shared prompt prefixes — big win when system/context repeat | |
| async_scheduling: bool = True # overlap CPU request scheduling with GPU compute | |
| enforce_eager: bool = False # skip CUDA-graph capture: faster cold start, lower steady-state throughput | |
| # Observability. ``log_requests`` adds --enable-log-requests so each call's id, | |
| # sampling params, and token counts show in the Modal container logs. | |
| log_requests: bool = True | |
| # OpenAI feature parsers (vLLM names; leave None if unsupported on the model) | |
| reasoning_parser: str | None = None | |
| tool_call_parser: str | None = None | |
| enable_auto_tool_choice: bool = False | |
| # Multimodal — per-prompt input caps, e.g. {"image": 4, "audio": 2}. Set the | |
| # caps to 0 on an auto-detected-multimodal model you serve text-only, to skip | |
| # the encoder warmup and free memory. | |
| mm_limits: dict[str, int] | None = None | |
| # Scaling / lifecycle | |
| max_concurrent_inputs: int = 64 # hard ceiling of requests multiplexed onto one container | |
| scaledown_window: int = 15 * 60 # idle seconds before a container stops | |
| min_containers: int = 0 # keep N warm to remove cold starts (costs $) | |
| startup_timeout: int = 30 * 60 # weight download + load can be slow | |
| request_timeout: int = 30 * 60 # max seconds a single request may run | |
| # Access | |
| gated: bool = False # repo needs a Hugging Face token | |
| # Escape hatches | |
| extra_vllm_args: tuple[str, ...] = () # raw flags appended verbatim | |
| env: dict[str, str] = field(default_factory=dict) # extra container env | |
| extra_pip: tuple[str, ...] = () # extra deps (audio/vision backends, etc.) | |
| def served_name(self) -> str: | |
| return self.served_model_name or self.name | |
| # --- Provider grouping --------------------------------------------------------- | |
| class Provider: | |
| """One isolated Modal app and the models it serves. | |
| The ``app`` name is half of every endpoint URL | |
| (``https://<workspace>--<app>-<endpoint_name>.modal.run/v1``), so it lives | |
| here — the single place app name and model list are paired — and both the | |
| ``app_<provider>.py`` deploy file and the engine read it from here. | |
| """ | |
| key: str # short handle, e.g. "nvidia" | |
| app: str # modal.App name, e.g. "nvidia-llms" | |
| label: str # display name, e.g. "NVIDIA" | |
| models: tuple[ModelConfig, ...] | |
| # --- NVIDIA (Nemotron) --------------------------------------------------------- | |
| NVIDIA_MODELS: tuple[ModelConfig, ...] = ( | |
| ModelConfig( | |
| name="nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16", | |
| endpoint_name="nemotron-3-nano-4b", | |
| # Tiny Titan tier (≤4B): ~4B BF16 weights (~8GB) fit a single 24GB L4. | |
| profile="tiny", | |
| params_b=4, | |
| gpu="L4:1", | |
| max_model_len=16384, | |
| # Hybrid Mamba-2 + MLP + attention arch → custom modeling code; required. | |
| trust_remote_code=True, | |
| gated=True, | |
| max_concurrent_inputs=32, | |
| # Served as a plain chat endpoint. NVIDIA ships a custom `nano_v3` reasoning | |
| # parser as a downloadable plugin file (--reasoning-parser-plugin) plus a | |
| # `qwen3_coder` tool parser; both are omitted here for boot-robustness (the | |
| # plugin must be shipped into the image and is easy to get wrong). The | |
| # model still reasons — the <think> block just stays inline in the content. | |
| # Add them later via extra_vllm_args if structured reasoning/tools are needed. | |
| ), | |
| # NOTE: nemotron-3-nano-30b (NVIDIA-Nemotron-3-Nano-30B-A3B-BF16, ~31B/A3B on an | |
| # A100) was removed to stay within the workspace's 8 Web-Function cap — it was an | |
| # unbound specialist (no tier, unreferenced by the engine/config), so dropping it | |
| # costs the live cast nothing. Re-add a ModelConfig here (and free a slot, or lift | |
| # the plan cap) to bring it back. See modal/README.md. | |
| ModelConfig( | |
| name="nvidia/Nemotron-Cascade-14B-Thinking", | |
| # Keep the slug short: the public URL is one DNS label | |
| # (<workspace>--<app>-<endpoint_name>.modal.run) capped at 63 chars, and a | |
| # longer "...-thinking" slug pushed it to 65 on a normal workspace, so the | |
| # host failed to resolve. The thinking-only nature is documented below, not | |
| # in the slug. See endpoint_url() and tests/test_modal_endpoint_urls.py. | |
| endpoint_name="nemotron-cascade-14b", | |
| # Dense 14B reasoning model built on Qwen3-14B Base; thinking-only. BF16 | |
| # weights (~28GB) plus KV cache fit a single 48GB L40S. A specialist | |
| # model — left unbound so it can be cast explicitly at a reasoning-heavy | |
| # agent (e.g. the Judge) without displacing a tier default. | |
| params_b=14, | |
| gpu="L40S:1", | |
| max_model_len=32768, | |
| # Post-trained from Qwen3-14B Base → stock Qwen3 arch (no custom code). | |
| # ChatML thinking block parsed by the Qwen3 reasoning parser; `hermes` is | |
| # the standard Qwen3-family tool parser. Both verified built-in in vLLM. | |
| reasoning_parser="qwen3", | |
| tool_call_parser="hermes", | |
| enable_auto_tool_choice=True, | |
| max_concurrent_inputs=48, | |
| ), | |
| ) | |
| # --- OpenBMB (MiniCPM) --------------------------------------------------------- | |
| OPENBMB_MODELS: tuple[ModelConfig, ...] = ( | |
| ModelConfig( | |
| name="openbmb/MiniCPM4.1-8B", | |
| endpoint_name="minicpm-4-1-8b", | |
| profile="fast", | |
| params_b=8, | |
| gpu="L40S:1", | |
| max_model_len=32768, | |
| trust_remote_code=True, | |
| max_concurrent_inputs=48, | |
| # No tool_call_parser on purpose: MiniCPM4.1 emits a custom | |
| # <|tool_call_start|> code-block format vLLM has no matching parser for, so | |
| # a tool parser would 400/mis-parse. The engine's structured path uses vLLM | |
| # guided decoding (response_format json_schema) instead, which is | |
| # parser-independent — see ADR-0016. Don't bolt on a mismatched parser. | |
| # (The model card suggests a vLLM nightly; 0.21.0 predates the release and | |
| # serves it fine — flip vllm_version="nightly" if a boot failure proves otherwise.) | |
| ), | |
| ModelConfig( | |
| name="openbmb/MiniCPM-o-4_5", | |
| endpoint_name="minicpm-o-4-5", | |
| # Omni-modal (text + vision + audio) on a Qwen3-8B backbone → ~9B total in | |
| # BF16. A specialist model, not cast to a profile by default. | |
| params_b=9, | |
| gpu="L40S:1", | |
| trust_remote_code=True, | |
| # Text + image only here; audio in/out over vLLM is experimental (it really | |
| # wants the Transformers/demo runtime). Caps keep the encoder warmup bounded. | |
| mm_limits={"image": 1, "audio": 0, "video": 0}, | |
| # Light vision/audio preprocessing backends. NOTE: full omni support wants | |
| # openbmb's `minicpmo-utils[all]` + a pinned transformers==4.51.0, but that | |
| # pin conflicts with vLLM's bundled transformers — so we keep the lean set | |
| # and serve text+image. Treat audio as experimental. | |
| extra_pip=("librosa", "soundfile", "timm"), | |
| gpu_memory_utilization=0.9, | |
| max_concurrent_inputs=16, | |
| # Custom omni-modal code path: keep the async scheduler off (conservative | |
| # — it's a specialist, not on the default cast). Prefix caching stays on. | |
| async_scheduling=False, | |
| ), | |
| ) | |
| # --- Google (Gemma) ------------------------------------------------------------ | |
| GOOGLE_MODELS: tuple[ModelConfig, ...] = ( | |
| ModelConfig( | |
| # Instruction-tuned repo — the right checkpoint for a balanced agent (the | |
| # base ``google/gemma-4-12B`` is pretrained-only). Both repos share the | |
| # ``gemma4_unified`` architecture, which vLLM 0.21.0 has no dedicated class | |
| # for, so it runs via the Transformers modeling backend either way. | |
| name="google/gemma-4-12B-it", | |
| # Keep the client-facing id stable (engine/tests/docs already use it); vLLM | |
| # serves the -it weights under this alias via --served-model-name. | |
| served_model_name="google/gemma-4-12B", | |
| endpoint_name="gemma-4-12b", | |
| profile="balanced", | |
| params_b=12, | |
| gpu="L40S:1", | |
| max_model_len=32768, | |
| gated=True, | |
| reasoning_parser="gemma4", | |
| tool_call_parser="gemma4", | |
| enable_auto_tool_choice=True, | |
| max_concurrent_inputs=48, | |
| # gemma4_unified (encoder-free) has no native class in any *stable* vLLM | |
| # (≤0.22.1 falls back to the Transformers backend and crashes); only the | |
| # nightly wheel registers Gemma4UnifiedForConditionalGeneration. So this | |
| # model alone pins the nightly + transformers>=5.10.2. Scoped here, so | |
| # NVIDIA/OpenBMB and the 26B sibling stay on the reproducible pin. | |
| vllm_version="nightly", | |
| extra_pip=("transformers>=5.10.2",), | |
| # Transformers-backend / fresh-nightly path: eager-only is the safe choice | |
| # (CUDA-graph capture + async scheduler aren't reliable here). | |
| enforce_eager=True, | |
| async_scheduling=False, | |
| # Text-only in the cast — gemma4 auto-detects as multimodal, so zero the | |
| # per-prompt caps to skip the encoder warmup and free memory for KV cache. | |
| mm_limits={"image": 0, "audio": 0}, | |
| ), | |
| ModelConfig( | |
| name="google/gemma-4-26B-A4B-it", | |
| endpoint_name="gemma-4-26b", | |
| # MoE: ~25B total params (~4B active) with a small vision encoder. Gated. | |
| profile="strong", | |
| params_b=26, | |
| gpu="A100", | |
| max_model_len=32768, | |
| gated=True, | |
| reasoning_parser="gemma4", | |
| tool_call_parser="gemma4", | |
| enable_auto_tool_choice=True, | |
| max_concurrent_inputs=64, | |
| # Standard gemma4 MoE arch (NOT the unified 12B path): served by a native | |
| # vLLM class on the pinned stable release (0.19.1+), so NO nightly, no | |
| # transformers pin, and CUDA graphs + async scheduling work — defaults stand. | |
| # Text-only in the cast, but image is this model's ONLY modality: zeroing it | |
| # (as the 12B does for image+audio) empties the active-modality set, and vLLM | |
| # 0.21.0's MultiModalBudget then calls max() on an empty sequence and crashes | |
| # on boot (compute_mm_encoder_budget). The 12B escapes this only because its | |
| # nightly wheel carries the defensive fix; on the stable pin we can't zero the | |
| # last modality. So keep one image slot: the vision encoder warmup is tiny and | |
| # the cast never sends images. Don't drop this to 0 without bumping vLLM. | |
| mm_limits={"image": 1}, | |
| ), | |
| ) | |
| # --- Provider registry --------------------------------------------------------- | |
| PROVIDERS: dict[str, Provider] = { | |
| "nvidia": Provider(key="nvidia", app="nvidia-llms", label="NVIDIA", models=NVIDIA_MODELS), | |
| "openbmb": Provider(key="openbmb", app="openbmb-llms", label="OpenBMB", models=OPENBMB_MODELS), | |
| "google": Provider(key="google", app="google-llms", label="Google", models=GOOGLE_MODELS), | |
| } | |
| # Convenience: every model across providers (handy for tooling / docs). | |
| ALL_MODELS: tuple[ModelConfig, ...] = tuple(m for p in PROVIDERS.values() for m in p.models) | |
| # --- Engine-facing view -------------------------------------------------------- | |
| class CatalogueEntry: | |
| """Flat, JSON-safe view of one served model — everything needed to *call* it. | |
| The engine builds its profile bindings from these (it never needs the full | |
| serving :class:`ModelConfig`), so adding a model here makes it bindable with | |
| no engine edits. ``key`` is the casting handle a profile points at. | |
| """ | |
| key: str # casting handle (== endpoint_name slug), e.g. "nemotron-3-nano-4b" | |
| provider: str # provider key, e.g. "nvidia" | |
| app: str # modal.App name, e.g. "nvidia-llms" | |
| endpoint_name: str # URL slug | |
| served_model_id: str # HF repo id vLLM serves (== ModelConfig.served_name) | |
| profile: str | None # default tier this model is cast for, or None | |
| params_b: float | None # total parameter count in billions | |
| def entries() -> tuple[CatalogueEntry, ...]: | |
| """Every model as a flat engine-facing record (keyed by ``endpoint_name``).""" | |
| return tuple( | |
| CatalogueEntry( | |
| key=m.endpoint_name, | |
| provider=p.key, | |
| app=p.app, | |
| endpoint_name=m.endpoint_name, | |
| served_model_id=m.served_name, | |
| profile=m.profile, | |
| params_b=m.params_b, | |
| ) | |
| for p in PROVIDERS.values() | |
| for m in p.models | |
| ) | |
| def litellm_model(served_model_id: str) -> str: | |
| """LiteLLM model string for an OpenAI-compatible custom endpoint.""" | |
| return f"openai/{served_model_id}" | |
| def endpoint_url(app: str, endpoint_name: str, workspace: str) -> str: | |
| """Public ``/v1`` URL Modal exposes for one endpoint in one workspace. | |
| Mirrors Modal's own naming: ``<workspace>--<app>-<endpoint_name>``. The | |
| workspace is the only deploy-specific part, so it is the lone argument the | |
| engine must supply from ``$MODAL_WORKSPACE``. | |
| """ | |
| return f"https://{workspace}--{app}-{endpoint_name}.modal.run/v1" | |