Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions nemoguardrails/server/experimental/application.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,29 @@ inbound access controls before listening beyond loopback. Caller authorization
headers still follow the existing upstream forwarding rules; this launcher
does not store provider keys or introduce service authentication.

### Choose the checking engine

The proxy uses the same `NEMO_GUARDRAILS_IORAILS_ENGINE` switch as the Guardrails
server. An unset variable, `0`, or `false` selects LLMRails. Setting `1`, `true`,
or `yes` selects the `Guardrails` wrapper, which uses IORails for compatible
configurations and falls back to LLMRails otherwise. Values are case-insensitive.
Set the variable before starting Python; changing it requires a process restart.

Run the same manifest with either selection to compare input/output checking:

```bash
NEMO_GUARDRAILS_IORAILS_ENGINE=0 uv run --locked python -m nemoguardrails.server.experimental serve proxy.yaml
NEMO_GUARDRAILS_IORAILS_ENGINE=1 uv run --locked python -m nemoguardrails.server.experimental serve proxy.yaml
```

Run one command at a time with the same upstream, Rails configuration and
streaming window settings. At INFO level, startup logs the actual engine for
each checker, including configurations that fall back to LLMRails. Confirm this
selection before comparing results. Engine selection changes the implementation
of the checks; provider generation, bindings and byte forwarding stay the same.
The proxy's checking path requires Colang 1.0 and retains its existing supported
input/output scope.

### Requests and blocking

The [example Rails configuration](examples/basic/rails/config.yml) blocks common
Expand Down
18 changes: 18 additions & 0 deletions nemoguardrails/server/experimental/configuration-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,19 @@ The deadline cannot preempt blocking synchronous work or force a resolver that
suppresses cancellation to stop. Preloading removes request-time engine
initialization; it does not make external policy selection free of latency.

## Checking engine

The catalog uses the server's `NEMO_GUARDRAILS_IORAILS_ENGINE` environment switch.
LLMRails is the default. Set the variable to `1`, `true`, or `yes` before Python
starts to prefer IORails for compatible configurations, with the same LLMRails
fallback as the server. Each checker's actual engine is logged at INFO level
during startup. See [engine comparison](application.md#choose-the-checking-engine)
for launch commands. Engine choice is process-wide and independent of trusted
request-time configuration selection.

The proxy accepts Colang 1.0 input/output checking configurations. Selecting
LLMRails does not enable dialog orchestration, retrieval or tool inspection.

## Lifecycle

Config IDs and directory paths are validated and fixed during application
Expand Down Expand Up @@ -188,6 +201,11 @@ work does not block the event loop. Engine construction and startup remain on
the application loop, before serving; arbitrary engine constructors are not
assumed to be thread-safe or non-blocking.

Plain LLMRails initializes during construction and exposes no asynchronous
startup or shutdown hook. The repository invokes these hooks when the selected
backend supplies them, including the IORails wrapper. This selection does not
add resource-cleanup capabilities to plain LLMRails or to custom actions.

The application lifespan starts, publishes, and closes `ServerRuntime`. The
runtime owns checker initialization and exposes whether engine cleanup remains
pending, so the lifespan does not inspect repository internals. Publication
Expand Down
7 changes: 4 additions & 3 deletions nemoguardrails/server/experimental/rails/_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,6 @@ async def check_async(
rail_types: list[RailType] | None = None,
) -> RailsResult: ...

async def shutdown(self) -> None: ...


def _message_document(message: GuardedMessage) -> dict[str, str]:
return {"role": message.role, "content": message.content}
Expand Down Expand Up @@ -123,4 +121,7 @@ async def check_output(self, check: OutputContentCheck) -> ContentCheckDecision:
return _decision(result)

async def close(self) -> None:
await self._rails.shutdown()
"""Close managed engines; plain LLMRails exposes no shutdown hook."""
shutdown = getattr(self._rails, "shutdown", None)
if shutdown is not None:
await shutdown()
18 changes: 14 additions & 4 deletions nemoguardrails/server/experimental/rails/_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@

import anyio

from nemoguardrails import Guardrails, RailsConfig
from nemoguardrails.server.experimental._content_checker import ContentChecker, validate_content_checker
from nemoguardrails import LLMRails, RailsConfig
from nemoguardrails.server.experimental._content_checker import (
ContentChecker,
UnsupportedContentCheckerConfiguration,
validate_content_checker,
)
from nemoguardrails.server.experimental.rails._adapter import LegacyGuardrailsChecker
from nemoguardrails.server.experimental.rails.config import ConfigEntry, validate_config_entries

Expand Down Expand Up @@ -64,10 +68,16 @@ async def startup(self) -> None:
return
for config_id, path in self._config_paths.items():
config = await asyncio.to_thread(RailsConfig.from_path, str(path))
rails = Guardrails(config=config, require_iorails=True)
if config.colang_version != "1.0":
raise UnsupportedContentCheckerConfiguration("Proxy content checks require Colang 1.0.")
rails = LLMRails(config=config)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The require_iorails flag defaults to False so this will fall back to LLMRails if there's an unsupported config with a warning log message rather than raising.

There are seven output rails which check $relevant_chunks which can't be passed in a checks_async() call to LLMRails, they have to be generated from generate_async(). But the LLMRails fallback logic doesn't use this fact. This is a silent fail-open for the following rails:

self check facts
alignscore check facts
fiddler bot faithfulness
patronus api check output
patronus lynx check output hallucination
autoalign groundedness output
self check hallucination

self._checkers[config_id] = LegacyGuardrailsChecker(rails)
validate_content_checker(self._checkers[config_id])
await rails.startup()
startup = getattr(rails, "startup", None)
if startup is not None:
await startup()
engine = getattr(rails, "rails_engine", rails)
log.info("Initialized proxy checker using %s.", type(engine).__name__)
self._ready = True
except BaseException:
await self.close()
Expand Down
4 changes: 3 additions & 1 deletion tests/server/experimental/test_application_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ def forbidden(*args, **kwargs):
pytest.fail("structural commands must not enter runtime construction or load Rails")

monkeypatch.setattr(_repository.RailsConfig, "from_path", forbidden)
monkeypatch.setattr(_repository, "Guardrails", forbidden)
monkeypatch.setattr(_repository, "LLMRails", forbidden)
monkeypatch.setattr(_application.ServerRuntime, "create", forbidden)
source = tmp_path / "rails/config.yml"
if filename is None:
Expand Down Expand Up @@ -419,6 +419,7 @@ def test_invalid_cli_usage(arguments):
@pytest.mark.asyncio
async def test_partial_catalog_startup_closes_real_core_workers_and_http_owners(tmp_path, document, monkeypatch):
"""A later parse failure must roll back the already-started real core."""
from nemoguardrails import Guardrails
from nemoguardrails.guardrails.iorails import IORails
from nemoguardrails.server.experimental._runtime import ServerRuntime

Expand All @@ -434,6 +435,7 @@ async def test_partial_catalog_startup_closes_real_core_workers_and_http_owners(
workers = []
actual_create = ServerRuntime.create
actual_start = IORails.start
monkeypatch.setattr(_repository, "LLMRails", Guardrails)

def create(cls, *args, **kwargs):
runtime = actual_create(*args, **kwargs)
Expand Down
32 changes: 16 additions & 16 deletions tests/server/experimental/test_guardrails_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,8 @@ async def test_application_preloads_even_unselected_configs_before_readiness(tmp
path = config_path(tmp_path)
engines = []

def create_engine(*, config, require_iorails):
def create_engine(*, config):
assert isinstance(config, RailsConfig)
assert require_iorails
index = len(engines)

async def start():
Expand All @@ -54,7 +53,7 @@ async def start():
engines.append(instance)
return instance

monkeypatch.setattr(_repository, "Guardrails", create_engine)
monkeypatch.setattr(_repository, "LLMRails", create_engine)
application = create_proxy_application(
providers=(create_openai_chat_integration(),),
rails_configs=(ConfigEntry("selected", path), ConfigEntry("unselected", path)),
Expand Down Expand Up @@ -96,13 +95,13 @@ async def test_unsupported_unselected_policy_prevents_readiness_and_closes_owner
)
engines = []

def create_engine(*, config, require_iorails):
def create_engine(*, config):
assert isinstance(config, RailsConfig)
instance = MagicMock(config=config, startup=AsyncMock(), shutdown=AsyncMock())
engines.append(instance)
return instance

monkeypatch.setattr(_repository, "Guardrails", create_engine)
monkeypatch.setattr(_repository, "LLMRails", create_engine)
transport = httpx.MockTransport(lambda _request: pytest.fail("no dispatch expected"))
transport.aclose = AsyncMock()
application = create_proxy_application(
Expand Down Expand Up @@ -139,7 +138,7 @@ async def test_application_startup_failure_rolls_back_catalog_and_http(tmp_path,
shutdown=AsyncMock(),
)
factory = MagicMock(side_effect=[first, failure if failure_at == "construct" else second])
monkeypatch.setattr(_repository, "Guardrails", factory)
monkeypatch.setattr(_repository, "LLMRails", factory)
if failure_at == "parse":
monkeypatch.setattr(
_repository.RailsConfig,
Expand Down Expand Up @@ -180,7 +179,7 @@ def read_config(_path):
config=RailsConfig.from_content(yaml_content="models: []"), startup=AsyncMock(), shutdown=AsyncMock()
)
monkeypatch.setattr(_repository.RailsConfig, "from_path", read_config)
monkeypatch.setattr(_repository, "Guardrails", MagicMock(return_value=instance))
monkeypatch.setattr(_repository, "LLMRails", MagicMock(return_value=instance))
repository = GuardrailsRepository((ConfigEntry("policy", config_path(tmp_path)),))
load = asyncio.create_task(repository.startup())
try:
Expand Down Expand Up @@ -209,7 +208,7 @@ async def startup():
startup=AsyncMock(side_effect=startup),
shutdown=AsyncMock(),
)
monkeypatch.setattr(_repository, "Guardrails", MagicMock(side_effect=[first, second]))
monkeypatch.setattr(_repository, "LLMRails", MagicMock(side_effect=[first, second]))
path = config_path(tmp_path)
repository = GuardrailsRepository((ConfigEntry("first", path), ConfigEntry("second", path)))
loading = asyncio.create_task(repository.startup())
Expand Down Expand Up @@ -248,7 +247,7 @@ async def startup():
startup=AsyncMock(side_effect=startup),
shutdown=AsyncMock(),
)
monkeypatch.setattr(_repository, "Guardrails", MagicMock(side_effect=[first, second]))
monkeypatch.setattr(_repository, "LLMRails", MagicMock(side_effect=[first, second]))
path = config_path(tmp_path)
repository = GuardrailsRepository((ConfigEntry("first", path), ConfigEntry("second", path)))
waiting = asyncio.create_task(repository.startup())
Expand Down Expand Up @@ -286,7 +285,7 @@ async def fail():
second = MagicMock(
config=RailsConfig.from_content(yaml_content="models: []"), startup=AsyncMock(), shutdown=AsyncMock()
)
monkeypatch.setattr(_repository, "Guardrails", MagicMock(side_effect=[first, second]))
monkeypatch.setattr(_repository, "LLMRails", MagicMock(side_effect=[first, second]))
path = config_path(tmp_path)
repository = GuardrailsRepository((ConfigEntry("first", path), ConfigEntry("second", path)))
await repository.startup()
Expand Down Expand Up @@ -315,7 +314,7 @@ async def shutdown():
startup=AsyncMock(),
shutdown=AsyncMock(side_effect=shutdown),
)
monkeypatch.setattr(_repository, "Guardrails", MagicMock(return_value=instance))
monkeypatch.setattr(_repository, "LLMRails", MagicMock(return_value=instance))
repository = GuardrailsRepository((ConfigEntry("policy", config_path(tmp_path)),))
await repository.startup()
with anyio.CancelScope() as scope:
Expand All @@ -329,6 +328,7 @@ async def shutdown():
async def test_repository_retries_real_core_shutdown_after_queue_timeout(tmp_path, monkeypatch):
"""Retain the real engine until interrupted worker cleanup completes."""

monkeypatch.setattr(_repository, "LLMRails", Guardrails)
repository = GuardrailsRepository((ConfigEntry("policy", config_path(tmp_path)),))
await repository.startup()
facade = repository._checkers["policy"]._rails
Expand Down Expand Up @@ -387,7 +387,7 @@ async def fail_shutdown():
startup=AsyncMock(),
shutdown=AsyncMock(side_effect=fail_shutdown),
)
monkeypatch.setattr(_repository, "Guardrails", MagicMock(return_value=engine))
monkeypatch.setattr(_repository, "LLMRails", MagicMock(return_value=engine))
runtime = ServerRuntime.create((ConfigEntry("policy", config_path(tmp_path)),), None)

try:
Expand Down Expand Up @@ -426,7 +426,7 @@ async def shutdown():
startup=AsyncMock(),
shutdown=AsyncMock(side_effect=shutdown),
)
monkeypatch.setattr(_repository, "Guardrails", MagicMock(return_value=instance))
monkeypatch.setattr(_repository, "LLMRails", MagicMock(return_value=instance))
runtime = ServerRuntime.create((ConfigEntry("policy", config_path(tmp_path)),), None)
await runtime.content_checkers.startup()
with anyio.fail_after(0.5):
Expand Down Expand Up @@ -488,7 +488,7 @@ async def test_repository_caches_and_closes_guardrails_by_config_id(tmp_path):
return_value=RailsConfig.from_content(yaml_content="models: []"),
),
patch(
"nemoguardrails.server.experimental.rails._repository.Guardrails",
"nemoguardrails.server.experimental.rails._repository.LLMRails",
return_value=guardrails,
) as guardrails_class,
):
Expand Down Expand Up @@ -518,7 +518,7 @@ async def test_repository_does_not_share_cores_between_config_ids(tmp_path):
"nemoguardrails.server.experimental.rails._repository.RailsConfig.from_path",
return_value=RailsConfig.from_content(yaml_content="models: []"),
),
patch("nemoguardrails.server.experimental.rails._repository.Guardrails", side_effect=guardrails),
patch("nemoguardrails.server.experimental.rails._repository.LLMRails", side_effect=guardrails),
):
await repository.startup()
first = repository.get("tenant-a")
Expand All @@ -541,7 +541,7 @@ async def test_repository_cannot_restart_after_startup_failure(tmp_path):
"nemoguardrails.server.experimental.rails._repository.RailsConfig.from_path",
return_value=RailsConfig.from_content(yaml_content="models: []"),
),
patch("nemoguardrails.server.experimental.rails._repository.Guardrails", return_value=guardrails),
patch("nemoguardrails.server.experimental.rails._repository.LLMRails", return_value=guardrails),
):
with pytest.raises(RuntimeError, match="broken config"):
await repository.startup()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def create_facade(**kwargs):
facades.append(facade)
return facade

monkeypatch.setattr(_repository, "Guardrails", create_facade)
monkeypatch.setattr(_repository, "LLMRails", create_facade)
try:
yield probe, facades
finally:
Expand Down
Loading
Loading