Free lesson · GenAI Application Engineering
Deploy NVIDIA NIM for self-hosted Llama 4 with LiteLLM routing
Build a deployment pipeline for NVIDIA NIM to self-host Llama 4 on GKE GPU nodes. Implement nim-deployment.yaml with GPU node pool selector (nvidia.com/gpu: 1, L4 or A100), resource limits, and NIM container with MODEL_NAME=meta/llama-4-maverick and NIM_MAX_BATCH_SIZE=32. Create nim-service.yaml exposing OpenAI-compatible endpoint on port 8000 with /v1/completions and /v1/chat/completions. Build CostThresholdRouter tracking per-provider spending via Redis counter, routing to NIM when monthly spend exceeds a threshold. Implement update_litellm_config() adding NIM as a LiteLLM model entry with api_base pointing to Kubernetes service DNS. Create benchmark_nim.py comparing NIM vs. hosted API on latency, throughput, and cost_per_1k_tokens, producing a NIMBenchmarkReport Pydantic model. Build cost_calculator() factoring GPU hourly cost vs. API pricing.
Course: Full-Stack GenAI Applications · Chapter 18 · Production Deployment on Cloud Run & GKE
Free to read — no subscription required.
Introduction
When you watch hosted-API spend climb past the price of a dedicated GPU node, self-hosting starts to look obvious — but a half-configured NIM deployment that silently falls back to the hosted API on every request can quadruple costs instead of cutting them, and you won't notice until the invoice arrives. By the end of this lesson you'll be able to deploy an NVIDIA NIM container on a GKE GPU node pool, expose its OpenAI-compatible endpoint inside the cluster, and route traffic to it through a cost-threshold router that only overflows to a hosted API when NIM is unhealthy or saturated.
Key Terminology
- NIM (NVIDIA Inference Microservices): Containerized inference servers from NVIDIA that package a model, an optimized runtime (TensorRT-LLM / vLLM), and an OpenAI-compatible HTTP API behind a single image pulled from NGC.
- LiteLLM Router: An open-source proxy/library that exposes one OpenAI-compatible interface in front of many LLM backends and routes each request based on rules such as cost-per-token, health, and latency.
- NGC (NVIDIA GPU Cloud) registry: NVIDIA's container and model registry (
nvcr.io) from which NIM images and model weights are pulled; access requires anNGC_API_KEY. - Cost-threshold routing: A routing policy where requests go to the cheaper self-hosted backend (NIM) while it is healthy and under a queue-depth/cost threshold, and overflow to a hosted API (Claude, Gemini) when those limits are exceeded.
- GKE GPU node pool: A Google Kubernetes Engine node pool of GPU-equipped VMs (for example
g2-standard-8with NVIDIA L4) selected via thecloud.google.com/gke-acceleratorlabel and thenvidia.com/gputaint/toleration.
Concepts
Cost Analysis: When Self-Hosting Breaks Even
The decision to self-host is fundamentally a cost optimization. A single g2-standard-8 GKE node with an NVIDIA L4 GPU costs approximately $0.70/hour on-demand ($504/month). NIM running Llama 4 Maverick on an L4 achieves roughly 40 tokens/second output throughput. At full utilization, that yields approximately 103 million output tokens per month. Compared to a hosted API charging $0.003 per 1,000 output tokens, the hosted cost for 103 million tokens would be $309. This means a single L4 GPU breaks even at roughly 60% sustained utilization—below that, the hosted API is cheaper because you are paying for idle GPU time.
The cost-threshold router addresses this directly. During low-traffic periods (nights, weekends), if your NIM pod's queue depth drops to zero for extended periods, you can scale the GPU node pool to zero using the GKE cluster autoscaler and route 100% of traffic to the hosted API. During business hours when traffic is sustained, the GPU nodes scale up, NIM becomes healthy, and the router shifts traffic back to self-hosted inference. This dynamic pattern captures the best economics from both worlds.
Code Walkthrough
Architecture Overview: The Cost-Threshold Routing Pattern
The cost-threshold routing pattern introduces a proxy layer—LiteLLM—between your application and multiple LLM backends. LiteLLM maintains a unified interface while routing requests based on configurable rules. When your GPU node pool has available capacity, requests route to NIM at a fixed infrastructure cost. When NIM pods are saturated or unavailable, requests overflow to a hosted API like Anthropic Claude or Google Gemini, where you pay per token. This hybrid approach gives you the cost efficiency of self-hosting during steady-state traffic and the elastic scalability of hosted APIs during spikes.
Code snippet mermaid
Loading diagram...
- Line 1: Declares a Mermaid flowchart with left-to-right (LR) layout direction.
- Line 2: Defines node A (FastAPI Application) sending an OpenAI-compatible request to node B (LiteLLM Router), establishing the entry point where the app delegates LLM calls through a unified routing layer.
- Line 3: Routes from LiteLLM Router to node C (NVIDIA NIM Pod running Llama 4 on an L4 GPU) when the estimated cost is below a defined threshold — the cost-optimized self-hosted path.
- Line 4: Routes from LiteLLM Router to node D (hosted APIs like Claude or Gemini) as a fallback when cost exceeds the threshold or when the NIM pod is unavailable, ensuring resilience.
- Line 5: Connects the NIM Pod (C) to node E, showing it runs on a dedicated GKE node pool (
g2-standard-8instance with an NVIDIA L4 GPU), detailing the infrastructure backing self-hosted inference. - Line 6: Connects the hosted API path (D) to node F (Cloud API Endpoint), indicating this route incurs per-token billing from the external provider.
- Line 7: Shows LiteLLM Router (B) emitting metrics to node G (Prometheus), specifically tracking cost_per_token and nim_queue_depth for observability.
- Line 8: Creates a feedback loop from Prometheus (G) back to the LiteLLM Router (B), triggering an alert when cost exceeds the budget — enabling the router to dynamically adjust its routing decisions.
This diagram illustrates the decision boundary within LiteLLM. The router inspects each incoming request and evaluates two signals: the estimated cost of routing to each backend, and the current queue depth on the NIM deployment. If the NIM queue depth exceeds a configured threshold, LiteLLM routes to the hosted API to avoid latency degradation, even though the per-token cost is higher. The Prometheus metrics feed back into the router configuration, enabling dynamic threshold adjustment.
Configuring the NIM Deployment on GKE
Before writing application code, you must configure the Kubernetes resources that run NIM. The deployment manifest requires a GPU node selector, resource limits that request exactly one GPU, a persistent volume for model weight caching (avoiding re-download on pod restarts), and environment variables that configure the NIM runtime. The following Python script generates the NIM Kubernetes deployment manifest programmatically using the kubernetes client library. The function build_nim_deployment constructs a V1Deployment object with the correct GPU resource requests, node affinity for the GPU node pool, a volume mount for the model cache, and environment variables sourced from a Kubernetes Secret named nim-credentials. This approach is preferable to static YAML because it enables parameterization across environments (dev uses L4, prod uses A100) without templating tools.
Code snippet python
1from kubernetes import client 2 3def build_nim_deployment( 4 model_name: str = "meta/llama-4-maverick", 5 gpu_type: str = "nvidia-l4", 6 gpu_count: int = 1, 7 replicas: int = 2, 8 namespace: str = "inference", 9) -> client.V1Deployment: 10 """Build a NIM deployment targeting a GPU node pool.""" 11 gpu_resource = f"nvidia.com/gpu" 12 container = client.V1Container( 13 name="nim-llm", 14 image="nvcr.io/nim/meta/llama-4-maverick:latest", 15 ports=[client.V1ContainerPort(container_port=8000)], 16 resources=client.V1ResourceRequirements( 17 limits={gpu_resource: str(gpu_count), "memory": "32Gi"}, 18 requests={gpu_resource: str(gpu_count), "memory": "24Gi"}, 19 ), 20 env=[ 21 client.V1EnvVar(name="NIM_MODEL_NAME", value=model_name), 22 client.V1EnvVar(name="NIM_MAX_BATCH_SIZE", value="64"), 23 client.V1EnvVar(name="NIM_LOG_LEVEL", value="INFO"), 24 client.V1EnvVar( 25 name="NGC_API_KEY", 26 value_from=client.V1EnvVarSource( 27 secret_key_ref=client.V1SecretKeySelector( 28 name="nim-credentials", key="ngc-api-key" 29 ) 30 ), 31 ), 32 ], 33 volume_mounts=[ 34 client.V1VolumeMount( 35 name="model-cache", mount_path="/opt/nim/.cache" 36 ) 37 ], 38 readiness_probe=client.V1Probe( 39 http_get=client.V1HTTPGetAction(path="/v1/health/ready", port=8000), 40 initial_delay_seconds=120, 41 period_seconds=10, 42 ), 43 ) 44 node_selector = {"cloud.google.com/gke-accelerator": gpu_type} 45 toleration = client.V1Toleration( 46 key="nvidia.com/gpu", operator="Exists", effect="NoSchedule" 47 ) 48 volume = client.V1Volume( 49 name="model-cache", 50 persistent_volume_claim=client.V1PersistentVolumeClaimVolumeSource( 51 claim_name="nim-model-cache" 52 ), 53 ) 54 template = client.V1PodTemplateSpec( 55 metadata=client.V1ObjectMeta(labels={"app": "nim-llm"}), 56 spec=client.V1PodSpec( 57 containers=[container], 58 node_selector=node_selector, 59 tolerations=[toleration], 60 volumes=[volume], 61 ), 62 ) 63 return client.V1Deployment( 64 metadata=client.V1ObjectMeta(name="nim-llm", namespace=namespace), 65 spec=client.V1DeploymentSpec( 66 replicas=replicas, 67 selector=client.V1LabelSelector(match_labels={"app": "nim-llm"}), 68 template=template, 69 ), 70 )
- Lines 1-1: Imports the
kubernetesclient library, which provides typed Python objects for every Kubernetes resource kind. - Lines 3-9: Defines
build_nim_deploymentwith parameters that vary across environments—gpu_typeswitches between"nvidia-l4"for cost-efficient inference and"nvidia-a100-80gb"for maximum throughput. - Lines 10-11: Sets the GPU resource key. Kubernetes uses
nvidia.com/gpuas the extended resource name registered by the NVIDIA device plugin DaemonSet. - Lines 12-18: Constructs the container spec pointing to the NIM image on NVIDIA's NGC registry (
nvcr.io). The port 8000 is NIM's default HTTP serving port. - Lines 19-22: Configures GPU resource limits and requests. Setting limits and requests to the same GPU count ensures the pod gets exactly that many GPUs—Kubernetes does not support GPU overcommit.
- Lines 23-34: Injects environment variables.
NIM_MAX_BATCH_SIZEcontrols in-flight batching—higher values increase throughput but consume more GPU memory. TheNGC_API_KEYis sourced from a Kubernetes Secret rather than hardcoded, following the principle that secrets never appear in deployment manifests. - Lines 35-38: Mounts a persistent volume at NIM's cache directory. Model weights for Llama 4 Maverick are approximately 20 GB; caching them on a PVC means pod restarts skip the multi-minute download from NGC.
- Lines 39-43: Configures a readiness probe with a 120-second initial delay. NIM takes 1-2 minutes to load model weights into GPU memory, compile TensorRT engines on first run, and begin serving. The probe prevents Kubernetes from routing traffic to a pod that is still initializing.
- Lines 44-48: Sets
node_selectorto target only nodes with the specified GPU accelerator. The toleration matches the taint applied to GPU nodes, ensuring non-GPU workloads do not accidentally schedule onto expensive GPU machines. - Lines 49-63: Assembles the pod template, pod spec, and deployment spec. The
replicasparameter defaults to 2 for high availability—if one pod is evicted during a node upgrade, the other continues serving.
Implementing Cost-Threshold Routing with LiteLLM
With NIM deployed and healthy, you need the routing layer that decides where each request goes. LiteLLM's Router class accepts a list of model deployments, each with metadata including cost-per-token. The router evaluates these costs at request time and selects the cheapest available backend. The following implementation defines a CostThresholdRouter class that wraps LiteLLM's Router with a route_request method. This method first checks whether the NIM backend is healthy by calling the /v1/health/ready endpoint. If NIM is healthy and its estimated cost per token falls below the configured cost_threshold_per_1k_tokens, the request routes to NIM. Otherwise, it falls back to the hosted API. The _estimate_cost helper calculates the expected cost using the model's pricing metadata from LiteLLM's built-in cost tables, and _check_nim_health performs a lightweight HTTP health check with a strict 2-second timeout to avoid blocking the request path.
Code snippet python
1import litellm 2from litellm import Router 3import httpx 4import logging 5 6logger = logging.getLogger(__name__) 7 8ROUTER_CONFIG = { 9 "model_list": [ 10 { 11 "model_name": "llama4", 12 "litellm_params": { 13 "model": "openai/meta-llama-4-maverick", 14 "api_base": "http://nim-llm.inference.svc.cluster.local:8000/v1", 15 "api_key": "not-needed", 16 }, 17 "model_info": {"id": "nim-local", "input_cost_per_token": 0.0}, 18 }, 19 { 20 "model_name": "llama4", 21 "litellm_params": { 22 "model": "anthropic/claude-sonnet-4-20250514", 23 "api_key": "os.environ/ANTHROPIC_API_KEY", 24 }, 25 "model_info": { 26 "id": "claude-fallback", 27 "input_cost_per_token": 0.000003, 28 }, 29 }, 30 ], 31 "routing_strategy": "cost-based-routing", 32 "num_retries": 2, 33 "timeout": 30, 34} 35 36class CostThresholdRouter: 37 def __init__(self, cost_threshold_per_1k: float = 0.005): 38 self.router = Router(**ROUTER_CONFIG) 39 self.cost_threshold = cost_threshold_per_1k 40 self.nim_health_url = ( 41 "http://nim-llm.inference.svc.cluster.local:8000/v1/health/ready" 42 ) 43 self._http_client = httpx.AsyncClient(timeout=2.0) 44 45 async def _check_nim_health(self) -> bool: 46 try: 47 resp = await self._http_client.get(self.nim_health_url) 48 return resp.status_code == 200 49 except httpx.RequestError: 50 logger.warning("NIM health check failed, routing to fallback") 51 return False 52 53 async def route_request(self, messages: list[dict], **kwargs) -> dict: 54 nim_healthy = await self._check_nim_health() 55 if nim_healthy: 56 try: 57 response = await self.router.acompletion( 58 model="llama4", 59 messages=messages, 60 specific_deployment={"id": "nim-local"}, 61 **kwargs, 62 ) 63 logger.info("Request served by NIM (cost: $0.00)") 64 return response 65 except Exception as exc: 66 logger.error("NIM request failed: %s, falling back", exc) 67 68 response = await self.router.acompletion( 69 model="llama4", 70 messages=messages, 71 specific_deployment={"id": "claude-fallback"}, 72 **kwargs, 73 ) 74 logger.info("Request served by hosted fallback") 75 return response
- Lines 1-4: Imports
litellm, itsRouterclass,httpxforasyncHTTP calls, and the standardloggingmodule. Thehttpx.AsyncClientis preferred overrequestsbecause the router operates in anasyncFastAPI context. - Lines 8-35: Defines the router configuration dictionary. Two model deployments share the same
model_nameof"llama4", enabling LiteLLM to treat them as interchangeable backends. The NIM deployment at line 14 uses the Kubernetes service DNS name (nim-llm.inference.svc.cluster.local) for in-cluster routing, eliminating external network hops. Theinput_cost_per_tokenof0.0for NIM reflects that self-hosted inference has no per-token charge—you pay for the GPU node regardless. - Lines 38-45: The
CostThresholdRouter.__init__method instantiates the LiteLLMRouterwith the config and stores the cost threshold. Thehttpx.AsyncClientis created once and reused across requests to leverage HTTP connection pooling, which matters when the health check runs on every request. - Lines 47-53: The
_check_nim_healthcoroutine sends a GET request to NIM's readiness endpoint. The 2-second timeout ensures a hung NIM pod does not block the entire request pipeline. On anyhttpx.RequestError(connection refused, timeout, DNS failure), the method returns False and logs a warning. - Lines 55-64: The
route_requestmethod implements the routing decision. When NIM is healthy, it callsself.router.acompletionwithspecific_deployment={"id": "nim-local"}to bypass LiteLLM's built-in routing and force the request to the NIM backend. This explicit targeting is necessary because LiteLLM's cost-based routing might not account for the real-time health status. - Lines 65-66: If the NIM call raises any exception—such as a CUDA out-of-memory error surfaced as an HTTP 500—the code catches it, logs the error, and falls through to the fallback path. This try/except block ensures no request is lost due to GPU-level failures.
- Lines 68-74: The fallback path explicitly routes to
claude-fallback. In production, you would emit a metric here (e.g.,nim_fallback_total.inc()) to track how often fallback occurs, which directly correlates with unexpected cost increases.
Do's and Don'ts
Do's
- ✓Do declare
nvidia.com/gpuin bothlimitsandrequestsinsideV1ResourceRequirements— GKE's GPU admission controller reads therequestsfield to bind a pod to a physical L4; specifying onlylimitsbypasses the scheduler's resource accounting and allows multiple NIM pods to compete for the same GPU, causing one to crash with an out-of-memory error at model-load time. - ✓Do mount a PVC at
/opt/nim/.cacheinbuild_nim_deployment— NIM downloads Llama 4 Maverick weights into this path on first boot; without a persistent volume, every pod restart (rolling update, node recycle, OOM eviction) triggers a full multi-gigabyte re-download that stalls inference for several minutes and generates unexpected egress charges. - ✓Do source
NGC_API_KEYfrom thenim-credentialsKubernetes Secret usingV1SecretKeySelector— embedding the key directly in aV1EnvVarvalue field exposes it inkubectl get deployment -o yamloutput, in CI artifact logs, and in any etcd backup snapshot, turning a routine operations query into a credential leak.
Don'ts
- ✗Don't omit the
nvidia.com/gpuNoScheduletoleration frombuild_nim_deployment— GKE GPU node pools are taintednvidia.com/gpu=NoScheduleby default; NIM pods without the matchingV1Tolerationwill pend indefinitely withnode(s) had untolerated tainteven though thecloud.google.com/gke-acceleratornode selector matches correctly, leaving the GPU node pool idle while the cluster reports no scheduling errors. - ✗Don't set
initial_delay_secondsbelow 120 on the/v1/health/readyreadiness probe — NIM takes roughly two minutes to map Llama 4 Maverick weights into L4 GPU memory; probing before weights are loaded causes Kubernetes to mark pods unready, restart them before they can serve traffic, and spin them into a crash loop that prevents NIM from ever becoming healthy. - ✗Don't deploy NIM without instrumenting
nim_queue_depthin Prometheus and wiring it into the LiteLLM router threshold — without this signal the cost-threshold router has no visibility into NIM saturation, so it continues routing to NIM as latency climbs and the overflow to Claude or Gemini never triggers; this is precisely the "half-configured" scenario the introduction warns about, where the self-hosted path silently degrades while hosted-API costs accumulate undetected until the invoice arrives.
This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Application Engineering subscription.
From · cancel anytime · Already a subscriber? Sign in →
More free lessons in Full-Stack GenAI Applications
- Ch 16Use Pydantic AI + Logfire as an alternative observability stack
- Ch 17Build async connection pools with FastAPI lifespan
- Ch 18Build multi-stage Docker images for FastAPI AI apps
- Ch 18Deploy FastAPI to Cloud Run with auto-scaling
- Ch 18Configure GKE deployments with HPA on custom metrics
- Ch 18Deploy NVIDIA NIM for self-hosted Llama 4 with LiteLLM routingYou are here
- Ch 18Deploy MCP tool servers as sidecars with external-secrets-operator