Skip to content

Commit f60de77

Browse files
authored
fix(agents): Honor shared CLI context for base URL and auth (#582)
* fix(agents): Honor shared CLI context for base URL and auth The `nemo agents` command group resolved its own base URL (only `--base-url`/`NEMO_BASE_URL`, else localhost) and attached no auth token, so it silently targeted the wrong platform and was rejected (401/403) on any secured cluster — breaking the local-to-remote deploy flow (AIRCORE-885). Add a shared `cli_context` module (resolve_base_url, resolve_context_headers) that reads the same CLIContext the rest of the CLI uses: - Base URL precedence: --base-url/NEMO_BASE_URL > `nemo config`/NMP_BASE_URL > localhost. The resolved target is echoed to stderr ("Targeting <url>") so mis-pointed commands are visible; stdout stays clean JSON. - Auth: attach the `nemo auth login` bearer token to the platform httpx calls (_api_request, gateway invoke) and to the usage-report SDK client. Applies to the platform commands and to `nemo agents usage show` (which builds its own SDK client). The module lives outside cli.py to avoid the cli.py <-> usage/cli.py import cycle. Tests: base-URL precedence + auth attachment (new test_cli_context_resolution.py), usage SDK client build with context base URL + auth, and stdout/stderr stream separation for JSON output. Signed-off-by: Tyler Bray <tbray@nvidia.com> * refactor(agents): Extract shared --base-url typer option Address review feedback: the --base-url option (and its help text) was repeated inline across all 12 platform commands plus `usage show`. Define it once in cli_context as a reusable Annotated `BaseUrlOption` (with the help text in a `BASE_URL_HELP` constant) and reuse it everywhere. Also reformat the help as a numbered resolution-order list. No behavior change. Signed-off-by: Tyler Bray <tbray@nvidia.com> --------- Signed-off-by: Tyler Bray <tbray@nvidia.com>
1 parent 1942d40 commit f60de77

6 files changed

Lines changed: 466 additions & 33 deletions

File tree

plugins/nemo-agents/src/nemo_agents_plugin/cli.py

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@
4545
import httpx
4646
import typer
4747
import yaml
48+
from nemo_agents_plugin.cli_context import (
49+
DEFAULT_BASE_URL as _DEFAULT_BASE_URL,
50+
)
51+
from nemo_agents_plugin.cli_context import (
52+
BaseUrlOption,
53+
)
54+
from nemo_agents_plugin.cli_context import (
55+
resolve_base_url as _resolve_base_url,
56+
)
57+
from nemo_agents_plugin.cli_context import (
58+
resolve_context_headers as _resolve_context_headers,
59+
)
4860
from nemo_agents_plugin.leaderboard.cli import register_leaderboard_commands
4961
from nemo_agents_plugin.usage.cli import register_usage_commands
5062
from nemo_platform.cli.core.formatters import Column, format_output
@@ -54,7 +66,6 @@
5466

5567
logger = logging.getLogger(__name__)
5668

57-
_DEFAULT_BASE_URL = "http://localhost:8080"
5869
_DEFAULT_WORKSPACE = "default"
5970
_LIST_OUTPUT_FORMAT = Literal["table", "json", "yaml", "csv", "markdown", "raw"]
6071
_AGENT_LIST_COLUMNS = [
@@ -145,7 +156,7 @@ def invoke(
145156
help="Name of a specific deployment to invoke (platform required).",
146157
),
147158
workspace: str = typer.Option(_DEFAULT_WORKSPACE, "--workspace", "-w"),
148-
base_url: str = typer.Option(_DEFAULT_BASE_URL, "--base-url", envvar="NEMO_BASE_URL"),
159+
base_url: BaseUrlOption = None,
149160
timeout: float = typer.Option(
150161
300,
151162
"--timeout",
@@ -160,6 +171,7 @@ def invoke(
160171
),
161172
) -> None:
162173
"""Invoke an agent — locally (with --agent-config) or via the platform (with --agent or --agent-deployment)."""
174+
base_url = _resolve_base_url(base_url)
163175
if agent_config:
164176
_local_invoke(agent_config, input, input_file, workspace=workspace, base_url=base_url)
165177
elif agent or agent_deployment:
@@ -628,9 +640,10 @@ def create(
628640
),
629641
description: str = typer.Option("", "--description"),
630642
workspace: str = typer.Option(_DEFAULT_WORKSPACE, "--workspace", "-w"),
631-
base_url: str = typer.Option(_DEFAULT_BASE_URL, "--base-url", envvar="NEMO_BASE_URL"),
643+
base_url: BaseUrlOption = None,
632644
) -> None:
633645
"""Register an agent on the platform."""
646+
base_url = _resolve_base_url(base_url)
634647
from nemo_agents_plugin.utils import inject_default_model
635648

636649
config_dict = _load_yaml(agent_config)
@@ -653,7 +666,7 @@ def create(
653666
def list_agents(
654667
ctx: typer.Context,
655668
workspace: str = typer.Option(_DEFAULT_WORKSPACE, "--workspace", "-w"),
656-
base_url: str = typer.Option(_DEFAULT_BASE_URL, "--base-url", envvar="NEMO_BASE_URL"),
669+
base_url: BaseUrlOption = None,
657670
output_format: Optional[_LIST_OUTPUT_FORMAT] = typer.Option(
658671
None,
659672
"--format",
@@ -671,6 +684,7 @@ def list_agents(
671684
),
672685
) -> None:
673686
"""List agents on the platform."""
687+
base_url = _resolve_base_url(base_url)
674688
resp = _api_request("GET", base_url, f"/apis/agents/v2/workspaces/{workspace}/agents")
675689
_print_list_response(
676690
ctx,
@@ -684,20 +698,22 @@ def list_agents(
684698
def get(
685699
name: str = typer.Argument(..., help="Agent name."),
686700
workspace: str = typer.Option(_DEFAULT_WORKSPACE, "--workspace", "-w"),
687-
base_url: str = typer.Option(_DEFAULT_BASE_URL, "--base-url", envvar="NEMO_BASE_URL"),
701+
base_url: BaseUrlOption = None,
688702
) -> None:
689703
"""Get an agent by name."""
704+
base_url = _resolve_base_url(base_url)
690705
resp = _api_request("GET", base_url, f"/apis/agents/v2/workspaces/{workspace}/agents/{name}")
691706
typer.echo(json.dumps(resp, indent=2))
692707

693708
@app.command(rich_help_panel="Agent Resources (requires running cluster)")
694709
def delete(
695710
name: str = typer.Argument(..., help="Agent name."),
696711
workspace: str = typer.Option(_DEFAULT_WORKSPACE, "--workspace", "-w"),
697-
base_url: str = typer.Option(_DEFAULT_BASE_URL, "--base-url", envvar="NEMO_BASE_URL"),
712+
base_url: BaseUrlOption = None,
698713
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
699714
) -> None:
700715
"""Delete an agent from the platform."""
716+
base_url = _resolve_base_url(base_url)
701717
if not yes:
702718
typer.confirm(f"Delete agent '{name}'?", abort=True)
703719
_api_request("DELETE", base_url, f"/apis/agents/v2/workspaces/{workspace}/agents/{name}")
@@ -725,7 +741,7 @@ def deploy(
725741
help="Maximum seconds to wait for a terminal status (only with --wait).",
726742
),
727743
workspace: str = typer.Option(_DEFAULT_WORKSPACE, "--workspace", "-w"),
728-
base_url: str = typer.Option(_DEFAULT_BASE_URL, "--base-url", envvar="NEMO_BASE_URL"),
744+
base_url: BaseUrlOption = None,
729745
) -> None:
730746
"""Deploy an agent on the platform.
731747
@@ -736,6 +752,7 @@ def deploy(
736752
scripted pipelines that prefer to poll separately via ``nemo agents
737753
deployments wait``.
738754
"""
755+
base_url = _resolve_base_url(base_url)
739756
payload: dict = {"agent": agent}
740757
if name:
741758
payload["name"] = name
@@ -792,7 +809,7 @@ def logs(
792809
help="Print only the absolute log file path and exit (useful for scripting).",
793810
),
794811
workspace: str = typer.Option(_DEFAULT_WORKSPACE, "--workspace", "-w"),
795-
base_url: str = typer.Option(_DEFAULT_BASE_URL, "--base-url", envvar="NEMO_BASE_URL"),
812+
base_url: BaseUrlOption = None,
796813
) -> None:
797814
"""Show logs for an agent deployment.
798815
@@ -816,6 +833,7 @@ def logs(
816833
raise typer.Exit(code=1)
817834

818835
if agent and not name:
836+
base_url = _resolve_base_url(base_url)
819837
candidates = [
820838
d
821839
for d in _unwrap_list(
@@ -859,10 +877,11 @@ def undeploy(
859877
None, "--agent", "--all", "-a", help="Remove all deployments for this agent."
860878
),
861879
workspace: str = typer.Option(_DEFAULT_WORKSPACE, "--workspace", "-w"),
862-
base_url: str = typer.Option(_DEFAULT_BASE_URL, "--base-url", envvar="NEMO_BASE_URL"),
880+
base_url: BaseUrlOption = None,
863881
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
864882
) -> None:
865883
"""Stop and remove a deployment (or all deployments for an agent)."""
884+
base_url = _resolve_base_url(base_url)
866885
if name:
867886
if not yes:
868887
typer.confirm(f"Undeploy '{name}'?", abort=True)
@@ -888,7 +907,7 @@ def undeploy(
888907
def deployments_list(
889908
ctx: typer.Context,
890909
workspace: str = typer.Option(_DEFAULT_WORKSPACE, "--workspace", "-w"),
891-
base_url: str = typer.Option(_DEFAULT_BASE_URL, "--base-url", envvar="NEMO_BASE_URL"),
910+
base_url: BaseUrlOption = None,
892911
output_format: Optional[_LIST_OUTPUT_FORMAT] = typer.Option(
893912
None,
894913
"--format",
@@ -906,6 +925,7 @@ def deployments_list(
906925
),
907926
) -> None:
908927
"""List deployments."""
928+
base_url = _resolve_base_url(base_url)
909929
resp = _api_request("GET", base_url, f"/apis/agents/v2/workspaces/{workspace}/deployments")
910930
_print_list_response(
911931
ctx,
@@ -919,20 +939,22 @@ def deployments_list(
919939
def deployments_get(
920940
name: str = typer.Argument(..., help="Deployment name."),
921941
workspace: str = typer.Option(_DEFAULT_WORKSPACE, "--workspace", "-w"),
922-
base_url: str = typer.Option(_DEFAULT_BASE_URL, "--base-url", envvar="NEMO_BASE_URL"),
942+
base_url: BaseUrlOption = None,
923943
) -> None:
924944
"""Get a deployment by name."""
945+
base_url = _resolve_base_url(base_url)
925946
resp = _api_request("GET", base_url, f"/apis/agents/v2/workspaces/{workspace}/deployments/{name}")
926947
typer.echo(json.dumps(resp, indent=2))
927948

928949
@deps_app.command(name="delete")
929950
def deployments_delete(
930951
name: str = typer.Argument(..., help="Deployment name."),
931952
workspace: str = typer.Option(_DEFAULT_WORKSPACE, "--workspace", "-w"),
932-
base_url: str = typer.Option(_DEFAULT_BASE_URL, "--base-url", envvar="NEMO_BASE_URL"),
953+
base_url: BaseUrlOption = None,
933954
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
934955
) -> None:
935956
"""Delete a deployment by name."""
957+
base_url = _resolve_base_url(base_url)
936958
if not yes:
937959
typer.confirm(f"Delete deployment '{name}'?", abort=True)
938960
_api_request("DELETE", base_url, f"/apis/agents/v2/workspaces/{workspace}/deployments/{name}")
@@ -950,7 +972,7 @@ def deployments_wait(
950972
timeout: int = typer.Option(300, "--timeout", "-t", help="Maximum seconds to wait."),
951973
interval: float = typer.Option(2.0, "--interval", help="Poll interval in seconds."),
952974
workspace: str = typer.Option(_DEFAULT_WORKSPACE, "--workspace", "-w"),
953-
base_url: str = typer.Option(_DEFAULT_BASE_URL, "--base-url", envvar="NEMO_BASE_URL"),
975+
base_url: BaseUrlOption = None,
954976
) -> None:
955977
"""Wait for a deployment to reach 'running' or 'failed' status.
956978
@@ -960,6 +982,7 @@ def deployments_wait(
960982
Provide either a deployment name directly or --agent to resolve the
961983
latest active deployment for that agent automatically.
962984
"""
985+
base_url = _resolve_base_url(base_url)
963986
if not name and not agent:
964987
typer.echo("Error: provide a deployment name or --agent.", err=True)
965988
raise typer.Exit(code=1)
@@ -1200,13 +1223,14 @@ def _platform_invoke(
12001223
path = f"/apis/agents/v2/workspaces/{workspace}/deployments/{deployment}/-/v1/chat/completions"
12011224

12021225
url = base_url.rstrip("/") + path
1226+
headers = _resolve_context_headers()
12031227
target_label = agent or deployment
12041228
for query in queries:
12051229
payload = {"messages": [{"role": "user", "content": query}], "stream": False}
12061230
try:
12071231
with request_progress(f"Waiting for agent '{target_label}'...", disabled=no_progress):
12081232
with httpx.Client(timeout=timeout) as client:
1209-
resp = client.post(url, json=payload)
1233+
resp = client.post(url, json=payload, headers=headers or None)
12101234
resp.raise_for_status()
12111235
body = resp.json()
12121236
typer.echo(json.dumps(body, indent=2))
@@ -1299,6 +1323,9 @@ def _api_request(method: str, base_url: str, path: str, *, json_body: dict[str,
12991323
request_kwargs: dict[str, Any] = {}
13001324
if json_body is not None:
13011325
request_kwargs["json"] = json_body
1326+
headers = _resolve_context_headers()
1327+
if headers:
1328+
request_kwargs["headers"] = headers
13021329
try:
13031330
with httpx.Client(timeout=30) as client:
13041331
resp = client.request(method, url, **request_kwargs)
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Shared CLI-context resolution for the ``nemo agents`` command group.
5+
6+
The agents plugin makes platform calls from two places — the platform
7+
commands in :mod:`nemo_agents_plugin.cli` (raw ``httpx``) and
8+
``nemo agents usage show`` in :mod:`nemo_agents_plugin.usage.cli` (the
9+
NeMoPlatform SDK client). Both must resolve the platform base URL and the
10+
auth token the same way every other ``nemo`` command does: through the
11+
shared CLI context object stored on ``typer.Context.obj``.
12+
13+
These helpers read the *ambient* Click context so callers deep in a command's
14+
call stack can resolve configuration without threading the context object
15+
through every function signature. They live in their own module (rather than
16+
in ``cli.py``) because ``cli.py`` imports the usage CLI at module load, so a
17+
back-import would create a cycle.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import logging
23+
from typing import Annotated, Any, Optional
24+
25+
import click
26+
import typer
27+
28+
logger = logging.getLogger(__name__)
29+
30+
DEFAULT_BASE_URL = "http://localhost:8080"
31+
32+
BASE_URL_HELP = (
33+
"Platform base URL. Resolution order: "
34+
"(1) this --base-url flag or NEMO_BASE_URL; "
35+
"(2) shared CLI config (`nemo config set --base-url`) or NMP_BASE_URL; "
36+
f"(3) {DEFAULT_BASE_URL} (default)."
37+
)
38+
39+
# Reusable ``--base-url`` option shared across every ``nemo agents`` command
40+
# so the option and its help text are defined once. ``None`` means "unset" so
41+
# ``resolve_base_url`` can fall back to the shared CLI context / config.
42+
BaseUrlOption = Annotated[
43+
Optional[str],
44+
typer.Option("--base-url", envvar="NEMO_BASE_URL", help=BASE_URL_HELP),
45+
]
46+
47+
48+
def current_cli_state() -> Any:
49+
"""Return the shared CLI context object (``typer.Context.obj``) if present.
50+
51+
Returns ``None`` when the plugin is exercised outside a Click invocation
52+
(e.g. a direct unit test), so callers fall back to their own defaults.
53+
"""
54+
ctx = click.get_current_context(silent=True)
55+
return ctx.obj if ctx is not None else None
56+
57+
58+
def base_url_from_context() -> str | None:
59+
"""Return the base URL configured in the shared CLI context, if any."""
60+
state = current_cli_state()
61+
if state is None or not hasattr(state, "get_base_url"):
62+
return None
63+
try:
64+
return state.get_base_url(default=None)
65+
except Exception:
66+
logger.debug("Failed to resolve base URL from CLI context", exc_info=True)
67+
return None
68+
69+
70+
def resolve_base_url(base_url: str | None) -> str:
71+
"""Resolve the platform base URL and announce the target on stderr.
72+
73+
Precedence:
74+
1. Explicit ``--base-url`` / ``NEMO_BASE_URL`` on the command.
75+
2. The shared CLI context — ``nemo config set --base-url`` and the
76+
``NMP_BASE_URL`` env var — so ``nemo agents`` targets the same
77+
platform as every other ``nemo`` command.
78+
3. The built-in localhost default.
79+
80+
The resolved target is echoed to stderr (never stdout, so piped/JSON
81+
output stays clean) so a mis-pointed command is visible instead of
82+
silently hitting the wrong platform.
83+
"""
84+
resolved = base_url or base_url_from_context() or DEFAULT_BASE_URL
85+
click.echo(f"Targeting {resolved}", err=True)
86+
return resolved
87+
88+
89+
def resolve_context_headers() -> dict[str, str]:
90+
"""Return auth (and other) default headers from the shared CLI context.
91+
92+
Mirrors ``nemo_platform_plugin.commands._resolve_submit_auth_headers``:
93+
reads the SDK client config off the shared context so ``nemo agents``
94+
attaches the same ``Authorization: Bearer`` token as the rest of the CLI
95+
(i.e. the token established by ``nemo auth login``). Returns an empty
96+
mapping when no context or token is available — leaving requests
97+
unauthenticated exactly as before, so local unauthenticated dev keeps
98+
working.
99+
"""
100+
state = current_cli_state()
101+
if state is None or not hasattr(state, "get_sdk_context"):
102+
return {}
103+
try:
104+
client_config = state.get_sdk_context().user.get_client_config()
105+
except Exception:
106+
logger.debug("Failed to resolve auth headers from CLI context", exc_info=True)
107+
return {}
108+
headers = client_config.get("default_headers") if isinstance(client_config, dict) else None
109+
if isinstance(headers, dict):
110+
return {str(key): str(value) for key, value in headers.items()}
111+
return {}

0 commit comments

Comments
 (0)