Skip to content

Commit 3b0b38e

Browse files
authored
fix(iorails): Add no-op events_history_cache when IORails is used (#2072)
1 parent 5d23eef commit 3b0b38e

5 files changed

Lines changed: 148 additions & 16 deletions

File tree

nemoguardrails/guardrails/guardrails.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -296,22 +296,24 @@ def update_llm(self, llm: LLMModel) -> None:
296296

297297
@property
298298
def events_history_cache(self) -> dict:
299-
"""Per-session events history cache. Only supported for LLMRails.
299+
"""Per-session events history cache.
300300
301301
Used by the server to persist conversation state across requests.
302-
Stored by reference; assigning replaces the dict object, not its
303-
contents.
302+
For LLMRails this is stored by reference; assigning replaces the dict
303+
object, not its contents.
304+
305+
IORails is stateless, return empty cache and drop cache-store writes
304306
"""
305307
if isinstance(self.rails_engine, IORails):
306-
raise NotImplementedError("IORails doesn't support events_history_cache attribute access")
308+
return {}
307309

308310
llmrails = cast(LLMRails, self.rails_engine)
309311
return llmrails.events_history_cache
310312

311313
@events_history_cache.setter
312314
def events_history_cache(self, value: dict) -> None:
313315
if isinstance(self.rails_engine, IORails):
314-
raise NotImplementedError("IORails doesn't support events_history_cache attribute access")
316+
return
315317

316318
llmrails = cast(LLMRails, self.rails_engine)
317319
llmrails.events_history_cache = value

nemoguardrails/guardrails/iorails.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,9 @@ def unsupported_reason(cls, config: RailsConfig, llm: Optional[LLMModel] = None)
241241
if llm is not None:
242242
return "an `llm` argument was provided; IORails does not accept a custom LLM"
243243

244+
if config.colang_version != "1.0":
245+
return f"IORails supports Colang 1.0 only; config uses Colang {config.colang_version}"
246+
244247
unsupported_rails = sorted(config.rails.model_fields_set - cls.SUPPORTED_RAILS)
245248
if unsupported_rails:
246249
return f"config has rails outside the IORails-supported set: {unsupported_rails}"

tests/guardrails/test_guardrails.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,19 @@ def test_llm_takes_precedence_over_config_issues(self):
348348
reason = IORails.unsupported_reason(config, llm=MagicMock())
349349
assert reason == "an `llm` argument was provided; IORails does not accept a custom LLM"
350350

351+
def test_colang_2x_config_is_unsupported(self):
352+
"""A Colang 2.x config is rejected: IORails has no Colang runtime, so 2.x falls back to LLMRails."""
353+
config = RailsConfig.from_content(config={"colang_version": "2.x"})
354+
reason = IORails.unsupported_reason(config, llm=None)
355+
assert reason == "IORails supports Colang 1.0 only; config uses Colang 2.x"
356+
assert IORails.can_handle(config, llm=None) is False
357+
358+
def test_llm_takes_precedence_over_colang_version(self):
359+
"""When both an llm is provided and the config is Colang 2.x, the llm reason is reported first."""
360+
config = RailsConfig.from_content(config={"colang_version": "2.x"})
361+
reason = IORails.unsupported_reason(config, llm=MagicMock())
362+
assert reason == "an `llm` argument was provided; IORails does not accept a custom LLM"
363+
351364
def test_can_handle_matches_reason_none(self, _content_safety_rails_config):
352365
"""``can_handle`` is a thin wrapper that returns True iff reason is None."""
353366
assert IORails.can_handle(_content_safety_rails_config, llm=None) is True

tests/guardrails/test_public_api_deprecations.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
default_embedding_{model,engine,params}, llm_generation_actions).
2323
* Deprecated read/write alias on LLMRails for explain_info.
2424
* First-class passthrough_fn property/setter on LLMRails (no warning).
25-
* Guardrails-facade proxies for explain_info (deprecated), passthrough_fn,
26-
and events_history_cache — including the IORails-engine raise paths.
25+
* Guardrails-facade proxies for explain_info (deprecated) and passthrough_fn,
26+
including the IORails-engine raise paths.
27+
* Guardrails-facade proxy for events_history_cache: delegates under LLMRails,
28+
inert under IORails (reads return {}, writes are dropped).
2729
"""
2830

2931
from unittest.mock import MagicMock, patch
@@ -224,7 +226,7 @@ def test_write_raises_under_iorails(self, iorails_guardrails):
224226

225227

226228
class TestGuardrailsFacadeEventsHistoryCache:
227-
"""Facade proxy for events_history_cache: plain getter+setter under LLMRails, raises under IORails."""
229+
"""Facade proxy for events_history_cache: plain getter+setter under LLMRails, inert under IORails."""
228230

229231
def test_read_delegates_to_llmrails(self, llmrails_guardrails):
230232
"""Reading guardrails.events_history_cache returns the wrapped LLMRails's plain attribute."""
@@ -239,12 +241,15 @@ def test_write_delegates_to_llmrails(self, llmrails_guardrails):
239241
llmrails_guardrails.events_history_cache = sentinel
240242
assert llmrails_guardrails.rails_engine.events_history_cache is sentinel
241243

242-
def test_read_raises_under_iorails(self, iorails_guardrails):
243-
"""Reading guardrails.events_history_cache on an IORails-backed facade raises NotImplementedError."""
244-
with pytest.raises(NotImplementedError, match=r"IORails doesn't support events_history_cache"):
245-
_ = iorails_guardrails.events_history_cache
244+
def test_read_returns_empty_dict_under_iorails(self, iorails_guardrails):
245+
"""Reading guardrails.events_history_cache on an IORails-backed facade returns an empty dict."""
246+
assert iorails_guardrails.events_history_cache == {}
246247

247-
def test_write_raises_under_iorails(self, iorails_guardrails):
248-
"""Writing guardrails.events_history_cache on an IORails-backed facade raises NotImplementedError."""
249-
with pytest.raises(NotImplementedError, match=r"IORails doesn't support events_history_cache"):
250-
iorails_guardrails.events_history_cache = {}
248+
def test_write_is_dropped_under_iorails(self, iorails_guardrails):
249+
"""Writing guardrails.events_history_cache on an IORails-backed facade is silently dropped.
250+
251+
IORails is stateless, so a write followed by a read still yields {} —
252+
the value is never stored and never consulted during generation.
253+
"""
254+
iorails_guardrails.events_history_cache = {"b": 2}
255+
assert iorails_guardrails.events_history_cache == {}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2023-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Server compatibility when NEMO_GUARDRAILS_IORAILS_ENGINE aliases LLMRails to Guardrails.
17+
18+
Under that env var, ``nemoguardrails.server.api`` builds a ``Guardrails`` wrapper that
19+
selects the stateless IORails engine for IORails-compatible configs. These tests
20+
reproduce the reported 500: ``_get_rails`` assigns ``events_history_cache``, which used
21+
to raise ``NotImplementedError`` on an IORails-backed wrapper. The alias is simulated by
22+
patching ``api.LLMRails`` to ``Guardrails`` and ``RailsConfig.from_path`` to return an
23+
IORails-compatible content-safety config, so the test does not depend on the global
24+
import-time env var.
25+
"""
26+
27+
import pytest
28+
from fastapi.testclient import TestClient
29+
30+
from nemoguardrails import Guardrails, RailsConfig
31+
from nemoguardrails.guardrails.iorails import IORails
32+
from nemoguardrails.server import api
33+
from tests.guardrails.test_data import CONTENT_SAFETY_CONFIG
34+
35+
36+
@pytest.fixture
37+
def iorails_compatible_config():
38+
"""An IORails-compatible Colang 1.0 content-safety config loaded once for the test."""
39+
return RailsConfig.from_content(config=CONTENT_SAFETY_CONFIG)
40+
41+
42+
@pytest.fixture(autouse=True)
43+
def reset_server_state():
44+
"""Clear the per-config rails caches and force multi-config mode around each test."""
45+
original_single_config_mode = api.app.single_config_mode
46+
api.app.single_config_mode = False
47+
api.llm_rails_instances.clear()
48+
api.llm_rails_events_history_cache.clear()
49+
yield
50+
api.llm_rails_instances.clear()
51+
api.llm_rails_events_history_cache.clear()
52+
api.app.single_config_mode = original_single_config_mode
53+
54+
55+
@pytest.fixture
56+
def iorails_alias(monkeypatch, iorails_compatible_config):
57+
"""Simulate NEMO_GUARDRAILS_IORAILS_ENGINE: alias LLMRails->Guardrails and serve the IORails config."""
58+
monkeypatch.setattr(api, "LLMRails", Guardrails)
59+
monkeypatch.setattr(api.RailsConfig, "from_path", staticmethod(lambda full_path: iorails_compatible_config))
60+
yield
61+
62+
63+
@pytest.mark.asyncio
64+
async def test_get_rails_does_not_raise_under_iorails_alias(iorails_alias):
65+
"""_get_rails returns an IORails-backed Guardrails with an inert events_history_cache instead of raising.
66+
67+
This is the exact crash site from the report: the events_history_cache assignment at
68+
the end of _get_rails. Under IORails it must round-trip inertly (read -> {}) rather
69+
than raise NotImplementedError.
70+
"""
71+
rails = await api._get_rails(["content_safety"])
72+
73+
assert isinstance(rails, Guardrails)
74+
assert isinstance(rails.rails_engine, IORails)
75+
assert rails.events_history_cache == {}
76+
77+
78+
def test_chat_completion_returns_200_under_iorails_alias(iorails_alias, monkeypatch):
79+
"""POST /v1/chat/completions returns 200 when the server runs on the IORails engine.
80+
81+
Generation is stubbed at the IORails boundary (start + generate_async) so the test
82+
exercises the server plumbing and the _get_rails cache assignment without any network
83+
or provider credentials; the response shape is asserted to be OpenAI-compatible.
84+
"""
85+
86+
async def _fake_start(self):
87+
self._running = True
88+
89+
async def _fake_generate_async(self, messages, **kwargs):
90+
return {"role": "assistant", "content": "hello from iorails"}
91+
92+
monkeypatch.setattr(IORails, "start", _fake_start)
93+
monkeypatch.setattr(IORails, "generate_async", _fake_generate_async)
94+
95+
client = TestClient(api.app, raise_server_exceptions=False)
96+
response = client.post(
97+
"/v1/chat/completions",
98+
json={
99+
"model": "test-model",
100+
"messages": [{"role": "user", "content": "Hello"}],
101+
"guardrails": {"config_id": "content_safety"},
102+
},
103+
)
104+
105+
assert response.status_code == 200
106+
res = response.json()
107+
assert res["object"] == "chat.completion"
108+
assert res["choices"][0]["message"]["role"] == "assistant"
109+
assert res["choices"][0]["message"]["content"] == "hello from iorails"

0 commit comments

Comments
 (0)