Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,66 @@
from nmp.core.models.entities import ModelDeployment as ModelDeploymentEntity
from nmp.core.models.entities import ModelDeploymentConfig as ModelDeploymentConfigEntity
from nmp.core.models.schemas import (
ContainerExecutorConfig,
CreateModelDeploymentConfigRequest,
Engine,
ModelDeploymentConfig,
ModelDeploymentConfigModelSpec,
ModelDeploymentStatus,
UpdateModelDeploymentConfigRequest,
)

logger = logging.getLogger(__name__)


def _validate_engine_config(
engine: Engine,
executor_config: ContainerExecutorConfig,
model_spec: ModelDeploymentConfigModelSpec,
) -> None:
"""Validate engine-specific requirements on the deployment config.

The ``generic`` engine runs an arbitrary container with no inference-engine
compiler, so it has no platform-default image and no canonical health
endpoint. Both ``image_name`` and ``health_check_path`` must therefore be
supplied explicitly; the other engines fall back to their configured
defaults when these are unset.

Values are also rejected when they contain surrounding whitespace: an
image reference or probe path is used verbatim downstream, where a
leading/trailing space would silently produce an invalid value.

LoRA is rejected for ``generic``: there is no engine compiler to wire the
adapter sidecar against, so ``lora_enabled`` would otherwise be silently
ignored. Reject it up front rather than accept a config that can't be honored.
"""
if engine != Engine.GENERIC:
return
missing: list[str] = []
padded: list[str] = []
for field in ("image_name", "health_check_path"):
value = getattr(executor_config, field)
if not (value and value.strip()):
missing.append(field)
elif value != value.strip():
padded.append(field)
if missing:
raise ValueError(
"The 'generic' engine requires executor_config."
Comment thread
benmccown marked this conversation as resolved.
+ " and executor_config.".join(missing)
+ " to be set (no platform default exists for a generic container)."
)
if padded:
raise ValueError(
"executor_config." + " and executor_config.".join(padded) + " must not have leading or trailing whitespace."
)
if model_spec.lora_enabled:
raise ValueError(
"The 'generic' engine does not support LoRA (model_spec.lora_enabled); "
"there is no engine compiler to wire the adapter sidecar against."
)


class ReferentialIntegrityError(Exception):
"""Exception raised when trying to delete a resource that has dependencies."""

Expand Down Expand Up @@ -104,6 +155,8 @@ async def create_deployment_config(
if existing is not None:
raise ValueError(f"Deployment config with workspace '{workspace}' and name '{request.name}' already exists")

_validate_engine_config(request.engine, request.executor_config, request.model_spec)

if not request.model_entity_id:
try:
model_workspace, model_name, _ = parse_model_name_revision(
Expand Down Expand Up @@ -255,6 +308,8 @@ async def update_deployment_config(
if not current:
raise ValueError(f"Deployment config with workspace '{workspace}' and name '{name}' does not exist")

_validate_engine_config(request.engine, request.executor_config, request.model_spec)

new_version = current.entity_version + 1

# Create new version entity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
from nmp.core.models.app import ModelWeightsType, get_model_weights_type, is_multi_llm_image, parse_model_name_revision
from nmp.core.models.app.constants import MODEL_MANAGED_BY_LABEL, MODEL_MANAGED_BY_MODELS_CONTROLLER
from nmp.core.models.app.utils import _get_k8s_safe_name
from nmp.core.models.controllers.backends import vllm_compiler
from nmp.core.models.controllers.backends import generic_compiler, vllm_compiler
from nmp.core.models.controllers.backends.backends import DeploymentStatusUpdate
from nmp.core.models.controllers.backends.common import deployment_config_view
from nmp.core.models.controllers.backends.docker.config import (
Expand All @@ -44,6 +44,7 @@
DockerBackendConfig,
)
from nmp.core.models.controllers.backends.engine import (
ENGINE_GENERIC,
ENGINE_HEALTH_PATHS,
ENGINE_LABEL,
ENGINE_NIM,
Expand Down Expand Up @@ -492,26 +493,34 @@ async def register_deployment(
self._backend_config.default_vllm_image,
self._backend_config.default_vllm_image_tag,
)
elif engine == ENGINE_GENERIC:
# Generic containers have no platform-default image; image_name is
# required (enforced at the API layer and again in the compiler).
image_name, image_tag = generic_compiler.resolve_generic_image(view)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else:
image_name = view.image_name or self._backend_config.default_nimservice_image
image_tag = view.image_tag or self._backend_config.default_nimservice_image_tag
full_image = f"{image_name}:{image_tag}"
logger.info(f"Using image: {full_image} (engine={engine})")

# Create volumes for model cache
# Create volumes for model cache + scratch. A generic container that pulls
# no weights runs raw (no platform volumes mounted -- see container create
# below), so skip provisioning them; every other case mounts them.
volume_name = self.get_volume_name(deployment.workspace, deployment.name)
try:
await asyncio.to_thread(self.create_volume, volume_name)
logger.info(f"Created volume: {volume_name}")
except Exception as e:
logger.warning(f"Failed to create volume {volume_name} (may already exist): {e}")

scratch_volume_name = volume_name + "-scratch"
try:
await asyncio.to_thread(self.create_volume, scratch_volume_name)
logger.info(f"Created volume: {scratch_volume_name}")
except Exception as e:
logger.warning(f"Failed to create volume {scratch_volume_name} (may already exist): {e}")
provision_volumes = engine != ENGINE_GENERIC or weights_from_files
if provision_volumes:
try:
await asyncio.to_thread(self.create_volume, volume_name)
logger.info(f"Created volume: {volume_name}")
except Exception as e:
logger.warning(f"Failed to create volume {volume_name} (may already exist): {e}")

try:
await asyncio.to_thread(self.create_volume, scratch_volume_name)
logger.info(f"Created volume: {scratch_volume_name}")
except Exception as e:
logger.warning(f"Failed to create volume {scratch_volume_name} (may already exist): {e}")

# Multi-LLM detection only applies to NIM images; vLLM/generic are never multi-LLM.
is_multi_llm = False
Expand Down Expand Up @@ -893,11 +902,20 @@ async def _advance_creating_container(
), True
state.tool_call_plugin_path = plugin_path

# Compile engine-specific environment variables (and serve args for vLLM).
vllm_serve_args: list[str] | None = None
if engine == ENGINE_VLLM:
# Compile engine-specific environment variables (and serve args for
# arg-configured engines: vLLM and generic). NIM is configured purely
# via env, so it leaves the container command unset.
serve_args: list[str] | None = None
if engine == ENGINE_GENERIC:
# Generic: run the image with the user's raw env + args verbatim. The
# platform synthesizes nothing (no served-model-name, no LoRA, etc.).
env_vars = generic_compiler.compile_generic_env_vars(view)
generic_args = generic_compiler.compile_generic_args(view)
# Only override the image's command when the user supplied args.
serve_args = generic_args or None
elif engine == ENGINE_VLLM:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
env_vars = vllm_compiler.compile_vllm_env_vars(view)
vllm_serve_args = vllm_compiler.compile_vllm_args(view, state.model_entity)
serve_args = vllm_compiler.compile_vllm_args(view, state.model_entity)
if view.lora_enabled:
# vLLM's lora_filesystem_resolver validates that
# VLLM_LORA_RESOLVER_CACHE_DIR exists at startup, before the adapter
Expand Down Expand Up @@ -1001,16 +1019,26 @@ async def cleanup_and_error(status_message: str, error_details: dict) -> tuple[D
try:
logger.info("Creating container %s with image %s...", container_name, full_image)

# Platform volumes (/model-store, /scratch) hold pulled weights + scratch
# space. NIM/vLLM always mount them. A generic container runs the user's
# image as-is, so only mount them when the platform actually pulls weights
# for it (a fileset-backed model deployment); otherwise the mounts would
# shadow the image's own contents at those paths.
mount_platform_volumes = engine != ENGINE_GENERIC or self._needs_puller(state)
volumes: dict[str, Any] = {}
if mount_platform_volumes:
volumes = {
state.volume_name: {"bind": "/model-store", "mode": "rw"},
state.scratch_volume_name: {"bind": "/scratch", "mode": "rw"},
}

create_args: dict[str, Any] = {
"image": full_image,
"name": container_name,
"environment": env_vars,
"detach": True,
"device_requests": device_requests,
"volumes": {
state.volume_name: {"bind": "/model-store", "mode": "rw"},
state.scratch_volume_name: {"bind": "/scratch", "mode": "rw"},
},
"volumes": volumes,
"labels": {
"nmp.nvidia.com/deployment-workspace": deployment.workspace,
"nmp.nvidia.com/deployment-name": deployment.name,
Expand All @@ -1021,10 +1049,12 @@ async def cleanup_and_error(status_message: str, error_details: dict) -> tuple[D
"restart_policy": {"Name": "unless-stopped"},
}

# vLLM serve args are passed as the container command (appended to the
# image's `vllm serve` entrypoint). NIM is configured purely via env.
if vllm_serve_args is not None:
create_args["command"] = vllm_serve_args
# Serve args are passed as the container command (appended to the
# image's entrypoint). vLLM uses its compiled `vllm serve` args;
# generic uses the user's raw additional_args. NIM is configured
# purely via env and leaves the command unset.
if serve_args is not None:
create_args["command"] = serve_args

if nim_config.gpu > 1:
fixed = MODELS_DOCKER_NIM_MULTI_GPU_SHM_SIZE or self._backend_config.nim_multi_gpu_shm_size
Expand Down Expand Up @@ -1055,7 +1085,10 @@ async def cleanup_and_error(status_message: str, error_details: dict) -> tuple[D
container_id = container.id[:12]
logger.info("Container %s started successfully (ID: %s)", container_name, container_id)

if view.lora_enabled:
# The generic engine has no LoRA semantics (no engine compiler to
Comment thread
benmccown marked this conversation as resolved.
# wire the adapter sidecar against), so never attach the sidecar for
# it even if lora_enabled was set.
if view.lora_enabled and engine != ENGINE_GENERIC:
cfg = get_platform_config()
image = get_qualified_image(self._backend_config.lora_sidecar_image_name)
sidecar_envs = cfg.to_shared_envvars()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Backend-agnostic compiler for the ``generic`` engine.

The ``generic`` engine runs an arbitrary container as-is: there is no
inference-engine compiler synthesizing args or env. The deployment declares a
container image + tag, a readiness-probe path, and (optionally) raw args and
environment variables; the platform runs the image and probes it.

Like :mod:`vllm_compiler`, these functions take a :class:`DeploymentConfigView`
and return plain data (image tuple, arg vector, env dict). They are NOT specific
to any service backend: the docker backend renders the result into a
``docker run`` container; the k8s backend renders it into a native Kubernetes
Deployment. Keep this module free of backend-specific imports so both can reuse
it.

Unlike the vLLM/NIM engines there is no platform-default image for a generic
container, so :func:`resolve_generic_image` raises when ``image_name`` is unset.
The create/update API layer validates this up front; the compiler enforces it
again as a defensive backstop.
"""

from nmp.core.models.controllers.backends.common import DeploymentConfigView


def resolve_generic_image(view: DeploymentConfigView) -> tuple[str, str]:
"""Resolve the generic container image name and tag.

There is no platform default for a generic image (it is an arbitrary
user-supplied container), so ``image_name`` is required. ``image_tag``
defaults to ``latest`` when unset, mirroring Docker/Kubernetes conventions.

Both values are trimmed: the API layer already rejects whitespace-padded
inputs for generic configs, but this stays robust to any other call path.
"""
if not (view.image_name and view.image_name.strip()):
raise ValueError("The 'generic' engine requires executor_config.image_name to be set (no platform default).")
image_name = view.image_name.strip()
image_tag = (view.image_tag or "").strip() or "latest"
return image_name, image_tag


def compile_generic_args(view: DeploymentConfigView) -> list[str]:
"""Return the container arg vector for a generic container.

The platform synthesizes nothing for the generic engine: the user's
``additional_args`` are the entire arg vector (appended to the image's own
entrypoint). Returns an empty list when none are supplied, in which case the
image's default command/args are used unchanged.
"""
return list(view.additional_args or [])


def compile_generic_env_vars(view: DeploymentConfigView) -> dict[str, str]:
"""Return the environment variables for a generic container.

Only the user's ``additional_envs`` are applied; the platform injects no
engine-specific environment for a generic container.
"""
return {str(k): str(v) for k, v in (view.additional_envs or {}).items()}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
deployment_config_view,
deployment_elapsed_seconds,
)
from nmp.core.models.controllers.backends.engine import ENGINE_GENERIC, ENGINE_VLLM, config_engine
from nmp.core.models.controllers.backends.engine import ENGINE_GENERIC, ENGINE_NIM, ENGINE_VLLM, config_engine
from nmp.core.models.controllers.backends.k8s_nim_operator.config import K8sNimOperatorConfig
from nmp.core.models.controllers.backends.k8s_nim_operator.reconcilers.base import Reconciler, ResolvedDeployment
from nmp.core.models.controllers.backends.k8s_nim_operator.reconcilers.k8s import K8sReconciler
Expand Down Expand Up @@ -235,22 +235,22 @@ def _resolve(self, ctx: ModelContext) -> ResolvedDeployment:
def _select_reconciler(self, engine: str) -> Optional[Reconciler]:
"""Select the reconciler for an engine.

Returns the vLLM reconciler for ``vllm``, the NIM-operator reconciler for
any other engine (the default), and ``None`` for ``generic`` -- which the
callers treat as the "unsupported engine" rejection (see
:meth:`_unsupported_engine`).
The direct-emission :class:`K8sReconciler` handles ``vllm`` and
``generic``; ``nim`` uses the NIM-operator reconciler. Any other value is
unsupported and yields ``None``, which the callers turn into the
"unsupported engine" rejection (see :meth:`_unsupported_engine`).
"""
if engine == ENGINE_VLLM:
if engine in (ENGINE_VLLM, ENGINE_GENERIC):
return self._k8s_reconciler
if engine == ENGINE_GENERIC:
return None
return self._nim_reconciler
if engine == ENGINE_NIM:
return self._nim_reconciler
return None

@staticmethod
def _unsupported_engine(engine: str) -> DeploymentStatusUpdate:
return DeploymentStatusUpdate(
status="ERROR",
status_message="The 'generic' engine is not yet supported on the k8s backend.",
status_message=f"The '{engine}' engine is not supported on the k8s backend.",
error_details={"error": "unsupported_engine", "engine": engine},
host_url=None,
)
Expand Down
Loading