Skip to content

Commit 0cf4a0f

Browse files
authored
fix(nemo-agents): strip workspace qualifier in eval preflight (#934)
* fix(nemo-agents): strip workspace qualifier in eval preflight validate_llm_models rejected workspace-qualified default models (e.g. default/nvidia-nemotron) because the VM route takes workspace as its own path segment. Strip a leading {workspace}/ before retrieve, mirroring IGW's OpenAI proxy removeprefix pattern. Fixes ASTD-271 Signed-off-by: Nathan Walston <nwalston@nvidia.com> * fix(nemo-agents): guard empty model name after workspace strip A model_name of exactly {workspace}/ (e.g. default/) normalized to an empty string, bypassing the pre-strip non-empty check and reaching the SDK. Re-check non-empty after removeprefix and skip. Adds dedup-across- normalized-forms and empty-after-strip regression tests (CodeRabbit). Signed-off-by: Nathan Walston <nwalston@nvidia.com> * refactor(nemo-agents): use parse_qualified_name for model workspace resolution Replace the manual removeprefix strip with nemo_platform_plugin.entities. parse_qualified_name, which splits any workspace qualifier and returns the target (workspace, name). A qualified model now routes to its own workspace (cross-workspace correct), not just the path workspace. Dedup and NotFound tracking move to the resolved (workspace, name) pair. Trims verbose comments per review. Signed-off-by: Nathan Walston <nwalston@nvidia.com> --------- Signed-off-by: Nathan Walston <nwalston@nvidia.com>
1 parent c30bac1 commit 0cf4a0f

2 files changed

Lines changed: 106 additions & 26 deletions

File tree

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

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import yaml
1818
from nemo_platform import NeMoPlatform, NotFoundError
19+
from nemo_platform_plugin.entities import parse_qualified_name
1920

2021
logger = logging.getLogger(__name__)
2122

@@ -317,9 +318,11 @@ def validate_llm_models(
317318
"""Pre-flight check that every IGW-routed LLM in *config* exists as a VirtualModel.
318319
319320
Iterates ``config["llms"]`` and, for each block whose ``_type`` is in
320-
:data:`_IGW_LLM_TYPES`, calls
321-
``sdk.inference.virtual_models.retrieve(model_name, workspace=workspace)``.
322-
Names are deduplicated before lookup so the same model declared under
321+
:data:`_IGW_LLM_TYPES`, strips a leading ``{workspace}/`` qualifier from
322+
``model_name`` (mirroring IGW's OpenAI proxy — the VM route takes
323+
workspace as its own path segment, so the bare name is what it expects)
324+
then calls ``sdk.inference.virtual_models.retrieve(name, workspace=workspace)``.
325+
Names are deduplicated *after* stripping so the same model declared under
323326
multiple LLM keys (e.g. agent + judge) costs one network call.
324327
325328
LLM blocks whose ``model_name`` still contains an unexpanded ``$VAR`` /
@@ -349,11 +352,9 @@ def validate_llm_models(
349352
if not isinstance(llms, dict):
350353
return
351354

352-
# Collect (llm_key, model_name) pairs we should validate, deduped by
353-
# model_name so multiple LLMs pointing at the same model only cost one
354-
# retrieve call. We keep the first llm_key we saw for each model_name
355-
# so error messages can point at a concrete YAML location.
356-
to_check: dict[str, str] = {}
355+
# Dedup by resolved (workspace, name) so the same model under multiple LLM
356+
# keys costs one retrieve; keep the first llm_key for error locations.
357+
to_check: dict[tuple[str, str], str] = {}
357358
for llm_key, llm_cfg in llms.items():
358359
if not isinstance(llm_cfg, dict):
359360
continue
@@ -370,31 +371,33 @@ def validate_llm_models(
370371
model_name,
371372
)
372373
continue
373-
to_check.setdefault(model_name, llm_key)
374+
# A model_name may be workspace-qualified ("prod/foo"); the VM route
375+
# takes workspace as its own path segment, so resolve it here.
376+
target_ws, target_name = parse_qualified_name(model_name, default_workspace=workspace)
377+
if not target_name:
378+
continue
379+
to_check.setdefault((target_ws, target_name), llm_key)
374380

375381
if not to_check:
376382
return
377383

378-
missing: list[tuple[str, str]] = [] # (model_name, llm_key)
379-
for model_name, llm_key in to_check.items():
384+
missing: list[tuple[str, str]] = [] # (qualified_name, llm_key)
385+
for (target_ws, target_name), llm_key in to_check.items():
380386
try:
381-
sdk.inference.virtual_models.retrieve(name=model_name, workspace=workspace)
387+
sdk.inference.virtual_models.retrieve(name=target_name, workspace=target_ws)
382388
except NotFoundError:
383-
missing.append((model_name, llm_key))
389+
missing.append((f"{target_ws}/{target_name}", llm_key))
384390
except Exception as exc: # pragma: no cover - defensive soft-fail
385-
# Soft-fail: log and continue. We don't want a transient platform
386-
# outage or auth blip to gate the eval/optimize run; the
387-
# subprocess will fail with the real error if the model truly
388-
# isn't reachable. ``exc_info`` preserves the traceback so a
389-
# debugger looking at the warning has the full chain of context
390-
# (which we'd otherwise drop by formatting ``exc`` via ``%s``).
391+
# Soft-fail: a transient platform outage or auth blip shouldn't gate
392+
# the run; the subprocess surfaces the real error. exc_info keeps the
393+
# traceback.
391394
logger.warning(
392-
"Could not validate LLM %r (model_name=%r) against workspace %r: %s. "
395+
"Could not validate LLM %r (model=%r) against workspace %r: %s. "
393396
"Continuing anyway; the underlying eval/optimize call will surface any "
394397
"real error.",
395398
llm_key,
396-
model_name,
397-
workspace,
399+
target_name,
400+
target_ws,
398401
exc,
399402
exc_info=exc,
400403
)

plugins/nemo-agents/tests/unit/test_utils.py

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1473,6 +1473,83 @@ def test_happy_path_dedupes_repeated_model_names(self) -> None:
14731473

14741474
assert vms.calls == [{"name": "shared-model", "workspace": "default"}]
14751475

1476+
def test_strips_workspace_qualifier_before_lookup(self) -> None:
1477+
"""A ``{workspace}/model`` value is stripped to bare ``model`` before retrieve.
1478+
1479+
Mirrors IGW's OpenAI proxy. ``nemo setup`` stores the qualified form as
1480+
the default, so ``${NEMO_DEFAULT_MODEL}`` resolves to e.g.
1481+
``default/nvidia-nemotron`` — the VM route takes workspace as a separate
1482+
path segment, so the bare name is what must reach the SDK.
1483+
"""
1484+
config = {
1485+
"llms": {
1486+
"agent_llm": {"_type": "openai", "model_name": "default/nvidia-nemotron"},
1487+
}
1488+
}
1489+
vms = _RecordingVirtualModels()
1490+
sdk = _StubSDKWithVirtualModels(vms)
1491+
1492+
validate_llm_models(config, workspace="default", sdk=sdk) # type: ignore[arg-type]
1493+
1494+
assert vms.calls == [{"name": "nvidia-nemotron", "workspace": "default"}]
1495+
1496+
def test_qualified_name_routes_to_its_own_workspace(self) -> None:
1497+
"""A qualified ``ws/model`` looks up ``model`` in ``ws``, not the path workspace.
1498+
1499+
``parse_qualified_name`` splits on the first ``/``: a qualifier always
1500+
wins over the fallback workspace, so a cross-workspace reference resolves
1501+
where it points. Bare names fall back to the path workspace.
1502+
"""
1503+
config = {
1504+
"llms": {
1505+
"bare": {"_type": "openai", "model_name": "plain-model"},
1506+
"other_ws": {"_type": "openai", "model_name": "prod/foo"},
1507+
}
1508+
}
1509+
vms = _RecordingVirtualModels()
1510+
sdk = _StubSDKWithVirtualModels(vms)
1511+
1512+
validate_llm_models(config, workspace="default", sdk=sdk) # type: ignore[arg-type]
1513+
1514+
calls = sorted((c["workspace"], c["name"]) for c in vms.calls)
1515+
assert calls == [("default", "plain-model"), ("prod", "foo")]
1516+
1517+
def test_qualified_and_bare_forms_dedupe_to_one_lookup(self) -> None:
1518+
"""``default/foo`` and ``foo`` normalize to the same name → one retrieve.
1519+
1520+
Dedup happens *after* stripping, so the same model spelled both ways
1521+
across LLM keys costs a single lookup.
1522+
"""
1523+
config = {
1524+
"llms": {
1525+
"qualified": {"_type": "openai", "model_name": "default/foo"},
1526+
"bare": {"_type": "openai", "model_name": "foo"},
1527+
}
1528+
}
1529+
vms = _RecordingVirtualModels()
1530+
sdk = _StubSDKWithVirtualModels(vms)
1531+
1532+
validate_llm_models(config, workspace="default", sdk=sdk) # type: ignore[arg-type]
1533+
1534+
assert vms.calls == [{"name": "foo", "workspace": "default"}]
1535+
1536+
def test_bare_workspace_prefix_normalizes_to_empty_and_is_skipped(self) -> None:
1537+
"""A ``model_name`` of exactly ``{workspace}/`` strips to '' and is skipped.
1538+
1539+
The empty string is not a routable model name; it must not reach the SDK.
1540+
"""
1541+
config = {
1542+
"llms": {
1543+
"empty_after_strip": {"_type": "openai", "model_name": "default/"},
1544+
}
1545+
}
1546+
vms = _RecordingVirtualModels()
1547+
sdk = _StubSDKWithVirtualModels(vms)
1548+
1549+
validate_llm_models(config, workspace="default", sdk=sdk) # type: ignore[arg-type]
1550+
1551+
assert vms.calls == []
1552+
14761553
def test_distinct_model_names_each_get_one_call(self) -> None:
14771554
config = {
14781555
"llms": {
@@ -1504,7 +1581,7 @@ def test_missing_model_raises_value_error_with_actionable_message(self) -> None:
15041581
message = str(exc_info.value)
15051582
# Names the missing model + the YAML key + the workspace, and points
15061583
# the user at concrete recovery steps.
1507-
assert "'missing-model'" in message
1584+
assert "'default/missing-model'" in message
15081585
assert "llms.judge_llm.model_name" in message
15091586
assert "'default'" in message
15101587
assert "NEMO_DEFAULT_MODEL" in message
@@ -1524,8 +1601,8 @@ def test_multiple_missing_models_listed_in_single_error(self) -> None:
15241601
validate_llm_models(config, workspace="default", sdk=sdk)
15251602

15261603
message = str(exc_info.value)
1527-
assert "'missing-1'" in message
1528-
assert "'missing-2'" in message
1604+
assert "'default/missing-1'" in message
1605+
assert "'default/missing-2'" in message
15291606

15301607
def test_non_igw_llm_types_are_skipped(self) -> None:
15311608
"""LLMs whose ``_type`` doesn't route through IGW aren't validatable here."""
@@ -1738,4 +1815,4 @@ def test_missing_model_propagates_validate_llm_models_error(self, tmp_path: Path
17381815

17391816
# Sanity-check the message shape; full message coverage lives in
17401817
# TestValidateLLMModels.
1741-
assert "'missing-model'" in str(exc_info.value)
1818+
assert "'default/missing-model'" in str(exc_info.value)

0 commit comments

Comments
 (0)