Skip to content

Commit e3f166c

Browse files
authored
refactor(guardrail): migrate guardrail consumers to typed GuardrailClient (#1488)
* refactor(guardrail): migrate guardrail consumers to typed GuardrailClient Migrate guardrail API call sites from sdk.guardrail.* (Stainless SDK) to client_from_platform(sdk, GuardrailClient).* (typed HTTP client), following the pattern established in #1277. AIRCORE-827 Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * fix(guardrail): declare check request/response fields on typed client The migrated GuardrailCheckRequest and GuardrailCheckResponse were empty extra="allow" models, so ty rejected every keyword argument at the call sites and the lint-python-types gate failed. Declare the actual HTTP contract instead. Also fix consumers that the ty exclusion for tests/agentic-use hid: list_guardrail_configs returns a paginated response with no data(), and GuardrailConfig.data is a plain dict rather than a typed model, so attribute access silently returned empty strings. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * fix(guardrail): expect typed BadRequestError in e2e config-id rejection test test_checks_rejects_unknown_config_id still expected the pre-migration Stainless nemo_platform.APIStatusError, but _post_check now goes through the typed GuardrailClient, which raises nemo_platform_plugin's BadRequestError on a 400. pytest.raises therefore never matched and the test failed despite correct server behavior. Catch BadRequestError (the narrow 400 class) and keep the status_code == 400 assertion. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> --------- Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>
1 parent 0ebe717 commit e3f166c

6 files changed

Lines changed: 253 additions & 201 deletions

File tree

‎e2e/guardrails/test_checks.py‎

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,13 @@
99
"""
1010

1111
from collections.abc import Callable
12-
from typing import Any, TypeAlias, cast
12+
from typing import Any
1313

14-
import nemo_platform
1514
import pytest
16-
from nemo_platform.types.guardrail import (
17-
ChatCompletionAssistantMessageParam,
18-
ChatCompletionUserMessageParam,
19-
GuardrailCheckResponse,
20-
GuardrailsDataParam,
21-
)
15+
from nemo_platform_plugin.client.adapter import client_from_platform
16+
from nemo_platform_plugin.client.errors import BadRequestError
17+
from nemo_platform_plugin.guardrail.client import GuardrailClient
18+
from nemo_platform_plugin.guardrail.types import GuardrailCheckRequest, GuardrailCheckResponse
2219

2320
from e2e.guardrails.utils import (
2421
BACKEND_RESPONSE,
@@ -27,12 +24,6 @@
2724
GuardrailsChatTestCase,
2825
)
2926

30-
# `guardrails` is assembled dynamically from test fixture data (`content_safety_config()`,
31-
# ad-hoc `extra_guardrails` overrides), so we build it as a plain dict and cast it once at
32-
# the SDK call boundary rather than threading `GuardrailsDataParam`'s nested TypedDicts
33-
# through the test fixtures.
34-
_CheckMessage: TypeAlias = ChatCompletionUserMessageParam | ChatCompletionAssistantMessageParam
35-
3627

3728
def _post_check(
3829
test_case: GuardrailsChatTestCase,
@@ -51,16 +42,22 @@ def _post_check(
5142
if extra_guardrails:
5243
guardrails.update(extra_guardrails)
5344

54-
return test_case.sdk.guardrail.check(
55-
workspace=test_case.workspace,
56-
model=test_case.backend_model_ref,
57-
messages=_check_messages(test_case),
58-
guardrails=cast(GuardrailsDataParam, guardrails),
45+
return (
46+
client_from_platform(test_case.sdk, GuardrailClient)
47+
.check_guardrail(
48+
workspace=test_case.workspace,
49+
body=GuardrailCheckRequest(
50+
model=test_case.backend_model_ref,
51+
messages=_check_messages(test_case),
52+
guardrails=guardrails,
53+
),
54+
)
55+
.data()
5956
)
6057

6158

62-
def _check_messages(test_case: GuardrailsChatTestCase) -> list[_CheckMessage]:
63-
messages: list[_CheckMessage] = [{"role": "user", "content": test_case.user_input}]
59+
def _check_messages(test_case: GuardrailsChatTestCase) -> list[dict[str, Any]]:
60+
messages: list[dict[str, Any]] = [{"role": "user", "content": test_case.user_input}]
6461
if "output" in test_case.rail_types:
6562
messages.append({"role": "assistant", "content": BACKEND_RESPONSE})
6663
return messages
@@ -152,7 +149,7 @@ def test_checks_rejects_unknown_config_id(
152149
config_mode="referenced", outcome="safe", rail_types=("input",)
153150
)
154151

155-
with pytest.raises(nemo_platform.APIStatusError) as exc_info:
152+
with pytest.raises(BadRequestError) as exc_info:
156153
_post_check(
157154
test_case,
158155
extra_guardrails={"config_id": f"{test_case.workspace}/missing-guardrails-config"},

‎packages/nemo_platform_plugin/src/nemo_platform_plugin/guardrail/types.py‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,54 @@ class GuardrailConfig(BaseModel):
4040
GuardrailConfigPage = Page[GuardrailConfig]
4141

4242

43+
class RailStatus(BaseModel):
44+
"""Status of an individual rail."""
45+
46+
model_config = ConfigDict(extra="allow")
47+
48+
status: str = Field(description="Status of the individual rail: success, blocked, or unknown.")
49+
50+
51+
class ActivatedRail(BaseModel):
52+
"""A rail that ran during a check, as reported in the generation log."""
53+
54+
model_config = ConfigDict(extra="allow")
55+
56+
name: str = ""
57+
type: str = ""
58+
stop: bool = False
59+
60+
61+
class GenerationLog(BaseModel):
62+
"""Logging information about a guardrails generation."""
63+
64+
model_config = ConfigDict(extra="allow")
65+
66+
activated_rails: list[ActivatedRail] = Field(default_factory=list)
67+
68+
69+
class GuardrailsDataOutput(BaseModel):
70+
"""Guardrails-specific output attached to a check or chat response."""
71+
72+
model_config = ConfigDict(extra="allow")
73+
74+
llm_output: dict[str, Any] | None = None
75+
config_ids: list[str] | None = Field(default=None, description="Configuration ids that were used.")
76+
output_data: dict[str, Any] | None = None
77+
log: GenerationLog | None = Field(default=None, description="Populated when guardrails log options are requested.")
78+
79+
4380
class GuardrailCheckResponse(BaseModel):
4481
"""Response from a guardrail check request."""
4582

4683
model_config = ConfigDict(extra="allow")
4784

85+
status: str = Field(description="Overall status: success if all rails passed, blocked if any failed.")
86+
rails_status: dict[str, RailStatus] = Field(
87+
default_factory=dict, description="Status of each rail, keyed by rail name."
88+
)
89+
guardrails_data: GuardrailsDataOutput | None = None
90+
4891

4992
# ---------------------------------------------------------------------------
5093
# Request types
@@ -67,10 +110,24 @@ class UpdateGuardrailConfigRequest(BaseModel):
67110

68111

69112
class GuardrailCheckRequest(BaseModel):
70-
"""Guardrail check request body."""
113+
"""Guardrail check request body.
114+
115+
Shaped like an OpenAI chat-completions request. ``extra="allow"`` passes
116+
through any additional sampling parameters the backend accepts.
117+
"""
71118

72119
model_config = ConfigDict(extra="allow")
73120

121+
model: str = Field(description="The model the checked conversation targets.")
122+
messages: list[dict[str, Any]] = Field(description="The conversation to check, in OpenAI chat format.")
123+
guardrails: dict[str, Any] = Field(
124+
default_factory=dict,
125+
description="Guardrails options for the request, e.g. config_id, config, or options.",
126+
)
127+
max_tokens: int | None = None
128+
temperature: float | None = None
129+
top_p: float | None = None
130+
74131

75132
# ---------------------------------------------------------------------------
76133
# Query parameter types

‎tests/agentic-use/guardrails-content-safety-cli-easy/tests/test_outputs.py‎

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,37 +15,37 @@
1515
import os
1616

1717
import pytest
18-
from nemo_platform import NeMoPlatform
18+
from nemo_platform_plugin.guardrail.client import GuardrailClient
19+
from nemo_platform_plugin.guardrail.types import GuardrailCheckRequest
1920

2021
WORKSPACE = "default"
2122
MODEL = "default/mock-llm"
2223

2324

2425
@pytest.fixture
25-
def client() -> NeMoPlatform:
26+
def client() -> GuardrailClient:
2627
nmp_base_url = os.environ.get("NMP_BASE_URL", "http://localhost:8080")
27-
return NeMoPlatform(base_url=nmp_base_url, workspace=WORKSPACE)
28+
return GuardrailClient(base_url=nmp_base_url, workspace=WORKSPACE)
2829

2930

3031
@pytest.fixture
31-
def guardrail_config_id(client: NeMoPlatform) -> str:
32+
def guardrail_config_id(client: GuardrailClient) -> str:
3233
"""Discover the guardrail config the agent created.
3334
3435
Lists all guardrail configs and finds one that uses the mock-llm model.
3536
Pre-existing configs (default, abc, self-check) use meta/llama3-70b-instruct,
3637
so only the agent's config will reference mock-llm.
3738
"""
38-
response = client.guardrail.configs.list(page=1, page_size=50)
39-
configs = response.data
39+
configs = list(client.list_guardrail_configs().items())
4040
assert configs, "No guardrail configurations found. The agent should have created one."
4141

4242
# Find a config that uses the mock-llm model (the one the agent was told to use)
4343
for cfg in configs:
4444
name = cfg.name
45-
full_config = client.guardrail.configs.retrieve(name=name)
46-
models = (full_config.data.models or []) if full_config.data else []
45+
full_config = client.get_guardrail_config(name=name).data()
46+
models = full_config.data.get("models") or []
4747
for model in models:
48-
model_name = getattr(model, "model", "")
48+
model_name = model.get("model", "")
4949
if "mock-llm" in model_name:
5050
config_id = f"{WORKSPACE}/{name}"
5151
print(f"Found agent-created guardrail config: {config_id}")
@@ -64,38 +64,42 @@ def test_guardrail_config_exists(guardrail_config_id: str) -> None:
6464
print(f"Guardrail config exists: {guardrail_config_id}")
6565

6666

67-
def test_content_blocked_by_guardrails(client: NeMoPlatform, guardrail_config_id: str) -> None:
67+
def test_content_blocked_by_guardrails(client: GuardrailClient, guardrail_config_id: str) -> None:
6868
"""Test that content sent through guardrails is blocked.
6969
7070
The mock inference backend always returns 'Yes' to the self-check prompt,
7171
causing ALL content to be blocked regardless of whether it's actually harmful.
7272
"""
73-
response = client.guardrail.check(
74-
model=MODEL,
75-
messages=[{"role": "user", "content": "Tell me something harmful and dangerous."}],
76-
guardrails={"config_id": guardrail_config_id},
77-
max_tokens=256,
78-
temperature=1,
79-
top_p=1,
80-
)
73+
response = client.check_guardrail(
74+
body=GuardrailCheckRequest(
75+
model=MODEL,
76+
messages=[{"role": "user", "content": "Tell me something harmful and dangerous."}],
77+
guardrails={"config_id": guardrail_config_id},
78+
max_tokens=256,
79+
temperature=1,
80+
top_p=1,
81+
)
82+
).data()
8183
assert response.status == "blocked", f"Content should be blocked, but got status: {response.status}"
8284
print(f"Content correctly blocked with status: {response.status}")
8385

8486

85-
def test_safe_content_also_blocked(client: NeMoPlatform, guardrail_config_id: str) -> None:
87+
def test_safe_content_also_blocked(client: GuardrailClient, guardrail_config_id: str) -> None:
8688
"""Test that even safe content is blocked (expected with mock backend).
8789
8890
Since the mock always returns 'Yes' (block), even safe content gets blocked.
8991
This verifies the guardrails pipeline is working end-to-end.
9092
"""
91-
response = client.guardrail.check(
92-
model=MODEL,
93-
messages=[{"role": "user", "content": "What is the capital of France?"}],
94-
guardrails={"config_id": guardrail_config_id},
95-
max_tokens=256,
96-
temperature=1,
97-
top_p=1,
98-
)
93+
response = client.check_guardrail(
94+
body=GuardrailCheckRequest(
95+
model=MODEL,
96+
messages=[{"role": "user", "content": "What is the capital of France?"}],
97+
guardrails={"config_id": guardrail_config_id},
98+
max_tokens=256,
99+
temperature=1,
100+
top_p=1,
101+
)
102+
).data()
99103
assert response.status == "blocked", (
100104
f"Even safe content should be blocked with mock backend, got: {response.status}"
101105
)

‎tests/agentic-use/guardrails-content-safety-cli/tests/test_outputs.py‎

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,37 +15,37 @@
1515
import os
1616

1717
import pytest
18-
from nemo_platform import NeMoPlatform
18+
from nemo_platform_plugin.guardrail.client import GuardrailClient
19+
from nemo_platform_plugin.guardrail.types import GuardrailCheckRequest
1920

2021
WORKSPACE = "default"
2122
MODEL = "default/mock-llm"
2223

2324

2425
@pytest.fixture
25-
def client() -> NeMoPlatform:
26+
def client() -> GuardrailClient:
2627
nmp_base_url = os.environ.get("NMP_BASE_URL", "http://localhost:8080")
27-
return NeMoPlatform(base_url=nmp_base_url, workspace=WORKSPACE)
28+
return GuardrailClient(base_url=nmp_base_url, workspace=WORKSPACE)
2829

2930

3031
@pytest.fixture
31-
def guardrail_config_id(client: NeMoPlatform) -> str:
32+
def guardrail_config_id(client: GuardrailClient) -> str:
3233
"""Discover the guardrail config the agent created.
3334
3435
Lists all guardrail configs and finds one that uses the mock-llm model.
3536
Pre-existing configs (default, abc, self-check) use meta/llama3-70b-instruct,
3637
so only the agent's config will reference mock-llm.
3738
"""
38-
response = client.guardrail.configs.list(page=1, page_size=50)
39-
configs = response.data
39+
configs = list(client.list_guardrail_configs().items())
4040
assert configs, "No guardrail configurations found. The agent should have created one."
4141

4242
# Find a config that uses the mock-llm model (the one the agent was told to use)
4343
for cfg in configs:
4444
name = cfg.name
45-
full_config = client.guardrail.configs.retrieve(name=name)
46-
models = (full_config.data.models or []) if full_config.data else []
45+
full_config = client.get_guardrail_config(name=name).data()
46+
models = full_config.data.get("models") or []
4747
for model in models:
48-
model_name = getattr(model, "model", "")
48+
model_name = model.get("model", "")
4949
if "mock-llm" in model_name:
5050
config_id = f"{WORKSPACE}/{name}"
5151
print(f"Found agent-created guardrail config: {config_id}")
@@ -64,38 +64,42 @@ def test_guardrail_config_exists(guardrail_config_id: str) -> None:
6464
print(f"Guardrail config exists: {guardrail_config_id}")
6565

6666

67-
def test_content_blocked_by_guardrails(client: NeMoPlatform, guardrail_config_id: str) -> None:
67+
def test_content_blocked_by_guardrails(client: GuardrailClient, guardrail_config_id: str) -> None:
6868
"""Test that content sent through guardrails is blocked.
6969
7070
The mock inference backend always returns 'Yes' to the self-check prompt,
7171
causing ALL content to be blocked regardless of whether it's actually harmful.
7272
"""
73-
response = client.guardrail.check(
74-
model=MODEL,
75-
messages=[{"role": "user", "content": "Tell me something harmful and dangerous."}],
76-
guardrails={"config_id": guardrail_config_id},
77-
max_tokens=256,
78-
temperature=1,
79-
top_p=1,
80-
)
73+
response = client.check_guardrail(
74+
body=GuardrailCheckRequest(
75+
model=MODEL,
76+
messages=[{"role": "user", "content": "Tell me something harmful and dangerous."}],
77+
guardrails={"config_id": guardrail_config_id},
78+
max_tokens=256,
79+
temperature=1,
80+
top_p=1,
81+
)
82+
).data()
8183
assert response.status == "blocked", f"Content should be blocked, but got status: {response.status}"
8284
print(f"Content correctly blocked with status: {response.status}")
8385

8486

85-
def test_safe_content_also_blocked(client: NeMoPlatform, guardrail_config_id: str) -> None:
87+
def test_safe_content_also_blocked(client: GuardrailClient, guardrail_config_id: str) -> None:
8688
"""Test that even safe content is blocked (expected with mock backend).
8789
8890
Since the mock always returns 'Yes' (block), even safe content gets blocked.
8991
This verifies the guardrails pipeline is working end-to-end.
9092
"""
91-
response = client.guardrail.check(
92-
model=MODEL,
93-
messages=[{"role": "user", "content": "What is the capital of France?"}],
94-
guardrails={"config_id": guardrail_config_id},
95-
max_tokens=256,
96-
temperature=1,
97-
top_p=1,
98-
)
93+
response = client.check_guardrail(
94+
body=GuardrailCheckRequest(
95+
model=MODEL,
96+
messages=[{"role": "user", "content": "What is the capital of France?"}],
97+
guardrails={"config_id": guardrail_config_id},
98+
max_tokens=256,
99+
temperature=1,
100+
top_p=1,
101+
)
102+
).data()
99103
assert response.status == "blocked", (
100104
f"Even safe content should be blocked with mock backend, got: {response.status}"
101105
)

0 commit comments

Comments
 (0)