|
| 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