Skip to content

Commit 663e76d

Browse files
committed
fix(agents): make deployed agent inference URL container-reachable
Deployed agents baked the API pod's own base URL into their NAT config llms.*.base_url via inject_gateway_url() at API-create time. Under embedded-PDP auth the API pod's NMP_BASE_URL is a loopback address (http://localhost:8080), and in k8s an agent pod resolves loopback to itself, so agent model calls never reach the platform. If the platform base URL instead pointed at an auth front-door proxy, agents looped forever on 503s and could DoS it (and everything behind it). The only mode-aware rewrite (container_gateway_url) fed a dead NMP_GATEWAY_BASE_URL env var that nothing reads, so neither the docker loopback rewrite nor any k8s fix ever reached the config the agent runs. This fix hands an agent only a base URL we know is container-reachable, never the raw platform base URL. - resolve_agent_gateway_url() returns a known-good target per mode: k8s uses the in-cluster API Service DNS; docker rewrites loopback (including IPv6 [::1]) to host.docker.internal and passes other hosts through. It raises for unsupported modes or when k8s has no internal URL, rather than deploying an agent that cannot reach the platform. - rewrite_config_base_urls() rebases each Inference Gateway llms.*.base_url onto that reachable address, preserving the path. - get_internal_base_url() reads NEMO_INTERNAL_BASE_URL then NMP_INTERNAL_BASE_URL; also agents.deployments.k8s_internal_base_url. - Removes the dead NMP_GATEWAY_BASE_URL env var. - Helm sets NMP_INTERNAL_BASE_URL to the internal API Service DNS on the api and controller pods. - Updates deploy-agents docs; adds unit tests for docker/k8s resolution, the fail-fast path, and config rebasing. Signed-off-by: Ben McCown <bmccown@nvidia.com>
1 parent 0c79158 commit 663e76d

8 files changed

Lines changed: 280 additions & 49 deletions

File tree

‎docs/agents/deploy-agents.mdx‎

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -335,28 +335,22 @@ for NVIDIA Build, OpenAI, and Anthropic examples.
335335

336336
<Note>
337337

338-
For container modes, the gateway URL injected into the agent must be reachable
339-
**from inside the container**, not just from the platform host. A loopback
340-
default like `http://localhost:8080` resolves to the container itself and the
341-
agent's model calls will fail. Set the platform's base URL to a
342-
container-reachable address — for example the Docker bridge address
343-
(`http://172.17.0.1:8080`) under Docker, or the in-cluster gateway address under
344-
Kubernetes.
338+
A deployed agent needs to reach the platform from **inside its container**. The
339+
deployment handles this automatically for both Docker and Kubernetes, so you
340+
normally don't need to configure anything. If an agent can't reach the platform,
341+
set `agents.deployments.gateway_url_override` to a URL that is reachable from
342+
inside the container.
345343

346344
</Note>
347345

348346
#### Docker mode on Linux
349347

350-
Docker Desktop (macOS/Windows) handles this automatically. On **Linux**, where
351-
`host.docker.internal` does not resolve inside containers, point the platform at
352-
the Docker bridge address (`172.17.0.1` by default) instead, or `nemo agents
353-
invoke` fails with `openai.APIConnectionError`.
354-
355-
Set both in `config.yaml`, then start the platform bound to all interfaces:
348+
On **Linux**, `host.docker.internal` doesn't resolve inside containers, so agent
349+
invokes can fail with `openai.APIConnectionError`. Point the deployment at the
350+
Docker bridge address (`172.17.0.1` by default) in `config.yaml`, then start the
351+
platform bound to all interfaces:
356352

357353
```yaml
358-
platform:
359-
base_url: http://172.17.0.1:8080
360354
agents:
361355
deployments:
362356
gateway_url_override: http://172.17.0.1:8080

‎k8s/helm/templates/api/api-deployment.yaml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ spec:
7575
{{- else }}
7676
value: {{ include "nemo-platform.internalBaseUrl" . | quote }}
7777
{{- end }}
78+
# In-cluster API Service DNS. Deployed agents use this as their inference
79+
# endpoint, since NMP_BASE_URL may not be reachable from an agent pod.
80+
- name: NMP_INTERNAL_BASE_URL
81+
value: {{ include "nemo-platform.internalBaseUrl" . | quote }}
7882
- name: NMP_AUTOMODEL_DEFAULT_TRAINING_EXECUTION_PROFILE
7983
value: {{ dig "automodel" "default_training_execution_profile" "default" $platformConfig | quote }}
8084
- name: NMP_UNSLOTH_DEFAULT_TRAINING_EXECUTION_PROFILE

‎k8s/helm/templates/core/controller-deployment.yaml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ spec:
5959
value: /etc/nmp/config.yaml
6060
- name: NMP_BASE_URL
6161
value: {{ include "nemo-platform.internalBaseUrl" . | quote }}
62+
# In-cluster API Service DNS. Deployed agents use this as their inference
63+
# endpoint, since NMP_BASE_URL may not be reachable from an agent pod.
64+
- name: NMP_INTERNAL_BASE_URL
65+
value: {{ include "nemo-platform.internalBaseUrl" . | quote }}
6266
{{- if include "nemo-platform.embeddedPdpEnabled" . }}
6367
- name: NMP_AUTH_POLICY_DECISION_POINT_BASE_URL
6468
value: {{ include "nemo-platform.internalBaseUrl" . | quote }}

‎plugins/nemo-agents/src/nemo_agents_plugin/config.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,17 @@ class DeploymentsRunnerConfig(BaseModel):
7777
gateway_url_override: str | None = Field(
7878
default=None,
7979
description=(
80-
"Optional container-reachable platform base URL. When unset, docker mode rewrites "
81-
"loopback hosts to host.docker.internal; k8s mode leaves the host base URL as-is "
82-
"(in-cluster IGW DNS is AIRCORE-863)."
80+
"Platform base URL baked into deployed agents as their inference endpoint, used verbatim "
81+
"for both docker and k8s. When unset, the deploy path derives a container-reachable URL: "
82+
"docker rewrites loopback hosts to host.docker.internal; k8s uses k8s_internal_base_url."
83+
),
84+
)
85+
k8s_internal_base_url: str | None = Field(
86+
default=None,
87+
description=(
88+
"In-cluster platform base URL (the API Service DNS, e.g. http://<release>-nmp-api:8080) "
89+
"used as the inference endpoint for k8s-mode agents. Set automatically by the Helm chart. "
90+
"Read from NEMO_INTERNAL_BASE_URL, then NMP_INTERNAL_BASE_URL, when unset."
8391
),
8492
)
8593
plugin_wheels_init_image: str | None = Field(

‎plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py‎

Lines changed: 89 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
from __future__ import annotations
1515

1616
import asyncio
17+
import copy
1718
import logging
1819
import time
1920
from typing import Any
21+
from urllib.parse import urlsplit
2022

2123
import yaml
2224
from nemo_agents_plugin.config import AgentsConfig, DeploymentsRunnerConfig
@@ -27,7 +29,7 @@
2729
Endpoint,
2830
)
2931
from nemo_agents_plugin.runner.backend import DeploymentInfo, ExternalLog, LogLocation, RunnerBackend
30-
from nemo_agents_plugin.utils import get_base_url
32+
from nemo_agents_plugin.utils import get_base_url, get_internal_base_url
3133
from nemo_deployments_plugin.entities import (
3234
ConfigFile,
3335
Container,
@@ -79,21 +81,77 @@ def map_status(backend_status: str) -> DeploymentStatus:
7981
return _STATUS_MAP.get(backend_status, "starting")
8082

8183

82-
def container_gateway_url(base_url: str, *, mode: DeploymentMode, override: str | None = None) -> str:
83-
"""Return a platform base URL reachable from inside the agent container.
84+
class UnreachableGatewayURLError(ValueError):
85+
"""Raised when no inference base URL reachable from an agent container can be resolved."""
8486

85-
Docker may rewrite loopback hosts to ``host.docker.internal``. K8s leaves the
86-
URL as-is (in-cluster IGW Service DNS is AIRCORE-863). *override* wins verbatim.
87+
88+
def resolve_agent_gateway_url(
89+
base_url: str,
90+
*,
91+
mode: DeploymentMode,
92+
override: str | None = None,
93+
internal_base_url: str | None = None,
94+
) -> str:
95+
"""Return the platform base URL an agent should call, reachable from its container.
96+
97+
An explicit *override* wins for any mode. Otherwise ``k8s`` uses
98+
*internal_base_url* (the in-cluster API Service DNS) and ``docker`` rewrites a
99+
loopback *base_url* to ``host.docker.internal``, passing other hosts through.
100+
101+
Only ``docker`` and ``k8s`` are supported; ``subprocess`` deployments are
102+
served by a different backend.
103+
104+
Raises:
105+
UnreachableGatewayURLError: k8s mode with no *internal_base_url* or *override*.
106+
ValueError: *mode* is not a container deployment mode.
87107
"""
108+
if mode not in CONTAINER_DEPLOYMENT_MODES:
109+
raise ValueError(
110+
f"resolve_agent_gateway_url only supports container deployment modes "
111+
f"{sorted(CONTAINER_DEPLOYMENT_MODES)}, got {mode!r}."
112+
)
113+
88114
if override:
89115
return override.rstrip("/")
90-
url = base_url.rstrip("/")
91-
if mode == "docker":
92-
for host in LOOPBACK_ADDRESSES:
93-
marker = f"//{host}"
94-
if marker in url:
95-
return url.replace(marker, "//host.docker.internal", 1)
96-
return url
116+
117+
if mode == "k8s":
118+
if internal_base_url:
119+
return internal_base_url.rstrip("/")
120+
raise UnreachableGatewayURLError(
121+
f"No container-reachable inference base URL for k8s deployment: platform base URL "
122+
f"{base_url!r} is not usable from an agent pod and no internal API Service URL is set. "
123+
"Set NEMO_INTERNAL_BASE_URL / NMP_INTERNAL_BASE_URL (or deployments.k8s_internal_base_url), "
124+
"or deployments.gateway_url_override."
125+
)
126+
127+
parts = urlsplit(base_url.rstrip("/"))
128+
if (parts.hostname or "").lower() in LOOPBACK_ADDRESSES:
129+
netloc = "host.docker.internal"
130+
if parts.port is not None:
131+
netloc = f"{netloc}:{parts.port}"
132+
return parts._replace(netloc=netloc).geturl()
133+
return parts.geturl()
134+
135+
136+
def rewrite_config_base_urls(nat_config: dict[str, Any], gateway_url: str) -> dict[str, Any]:
137+
"""Return a copy of *nat_config* with each Inference Gateway LLM base_url rebased onto *gateway_url*.
138+
139+
Rewrites the scheme, host, and port of ``base_url`` on every ``openai``/``nim``
140+
LLM that points at the Inference Gateway, preserving the path. LLMs with an
141+
explicit third-party ``base_url`` are left unchanged.
142+
"""
143+
reachable = urlsplit(gateway_url.rstrip("/"))
144+
reachable_origin = f"{reachable.scheme}://{reachable.netloc}"
145+
config = copy.deepcopy(nat_config)
146+
for llm_cfg in config.get("llms", {}).values():
147+
if not isinstance(llm_cfg, dict) or llm_cfg.get("_type") not in ("openai", "nim"):
148+
continue
149+
current = llm_cfg.get("base_url")
150+
if not isinstance(current, str) or "/apis/inference-gateway/" not in current:
151+
continue
152+
parts = urlsplit(current)
153+
llm_cfg["base_url"] = f"{reachable_origin}{parts.path}"
154+
return config
97155

98156

99157
def executor_for_mode(config: DeploymentsRunnerConfig, mode: DeploymentMode) -> str | None:
@@ -139,7 +197,6 @@ def build_deployment_config(
139197
nat_config: dict[str, Any],
140198
config_mount_path: str,
141199
mode: DeploymentMode,
142-
gateway_base_url: str,
143200
plugin_wheels_init_image: str | None = None,
144201
labels: dict[str, str] | None = None,
145202
) -> DeploymentConfig:
@@ -150,10 +207,13 @@ def build_deployment_config(
150207
``NAT_CONFIG_YAML`` and a shell preamble that writes the file before ``nat``
151208
starts. The main container binds ``0.0.0.0`` and exposes a readiness probe on
152209
``/health``.
210+
211+
The inference base URL the agent calls is read from ``nat_config``'s
212+
``llms.*.base_url``; the caller is responsible for setting it to a
213+
container-reachable value.
153214
"""
154215
nat_yaml = yaml.safe_dump(nat_config, sort_keys=False)
155216
env = [
156-
EnvVar(name="NMP_GATEWAY_BASE_URL", value=gateway_base_url),
157217
EnvVar(name="NMP_WORKSPACE", value=workspace),
158218
EnvVar(name="NMP_AGENT_NAME", value=name),
159219
EnvVar(name=_NAT_CONFIG_ENV, value=config_mount_path),
@@ -292,11 +352,21 @@ async def create_deployment(
292352
)
293353

294354
entities = self._entity_client()
295-
gateway = container_gateway_url(
296-
get_base_url(),
297-
mode=deployment_mode,
298-
override=self._config.gateway_url_override,
299-
)
355+
# The base_url injected into the agent config at agent-create time is the
356+
# platform's own base URL, which is not necessarily reachable from inside
357+
# the agent container. Rebase it onto a container-reachable address.
358+
try:
359+
internal_base_url = self._config.k8s_internal_base_url or get_internal_base_url()
360+
gateway = resolve_agent_gateway_url(
361+
get_base_url(),
362+
mode=deployment_mode,
363+
override=self._config.gateway_url_override,
364+
internal_base_url=internal_base_url,
365+
)
366+
except UnreachableGatewayURLError as exc:
367+
logger.error("Refusing to deploy agent %r: %s", name, exc)
368+
return DeploymentInfo(name=name, status="failed", error=str(exc))
369+
config = rewrite_config_base_urls(config, gateway)
300370
deployment_config = build_deployment_config(
301371
name=name,
302372
workspace=workspace,
@@ -305,7 +375,6 @@ async def create_deployment(
305375
nat_config=config,
306376
config_mount_path=self._config.config_mount_path,
307377
mode=deployment_mode,
308-
gateway_base_url=gateway,
309378
plugin_wheels_init_image=self._config.plugin_wheels_init_image,
310379
labels={
311380
"nemo.agents/deployment": name,

‎plugins/nemo-agents/src/nemo_agents_plugin/utils.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,17 @@ def get_base_url() -> str:
171171
)
172172

173173

174+
def get_internal_base_url() -> str | None:
175+
"""Return the in-cluster platform base URL reachable from inside agent pods, or None.
176+
177+
This is the API Service DNS used to reach the platform from a deployed agent
178+
when :func:`get_base_url` is not routable from inside the container. Read from
179+
``NEMO_INTERNAL_BASE_URL``, then ``NMP_INTERNAL_BASE_URL``.
180+
"""
181+
internal = os.environ.get("NEMO_INTERNAL_BASE_URL") or os.environ.get("NMP_INTERNAL_BASE_URL")
182+
return internal.rstrip("/") if internal else None
183+
184+
174185
def get_default_model() -> str | None:
175186
"""Return the default model for the platform from the SDK context."""
176187
from nemo_platform.config import get_context

0 commit comments

Comments
 (0)