Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
49ae76f
Initial checkin of GenerationResponse code (non-streaming only, no to…
tgasser-nv Jul 16, 2026
054dca7
Promote options to a named argument rather than smuggled in through k…
tgasser-nv Jul 16, 2026
b72afc0
Add functionality for LLMRails non-Colang GenerationResponse parity
tgasser-nv Jul 16, 2026
07c4b29
Change test_log_request_flag_raises to test colang-only log fields raise
tgasser-nv Jul 17, 2026
604deab
Add tests for missing coverage
tgasser-nv Jul 17, 2026
95c0a74
Add missing GenerationResponse fields
tgasser-nv Jul 17, 2026
3001e83
Match LLMRails underscore-separated task names rather than space-sepa…
tgasser-nv Jul 17, 2026
a04d242
Add prompt and response to GenerationResponse activated_rails.execute…
tgasser-nv Jul 17, 2026
559ace5
Add missing test coverage
tgasser-nv Jul 17, 2026
7912d6c
Compact and clean up tests
tgasser-nv Jul 17, 2026
cdee6c3
Revert change to nemoguards config main model to meta/llama-3.3-70b-i…
tgasser-nv Jul 17, 2026
4521e06
Fix generate_async() overloads and normalize Guardrails and IORails w…
tgasser-nv Jul 20, 2026
9ec3bd4
Return GenerationResponse status of all rails when one fails in paral…
tgasser-nv Jul 20, 2026
2ebb17b
Record failed calls in RailLLMCall for GenerationResponse
tgasser-nv Jul 20, 2026
c2762a6
Serialize all message types
tgasser-nv Jul 20, 2026
c913b0f
Time speculative main model call too
tgasser-nv Jul 20, 2026
e3ac3d2
Record speculative generation tokens which are then discarded due to …
tgasser-nv Jul 21, 2026
4c37335
Add missing line coverage
tgasser-nv Jul 21, 2026
92f284f
Clean up and parameterize tests
tgasser-nv Jul 21, 2026
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
9 changes: 6 additions & 3 deletions nemoguardrails/base_guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@
"""

from abc import ABC, abstractmethod
from typing import Any, AsyncIterator
from typing import Any, AsyncIterator, Optional, Union

from nemoguardrails.rails.llm.config import RailsConfig
from nemoguardrails.rails.llm.options import GenerationOptions


class BaseGuardrails(ABC):
Expand All @@ -44,12 +45,14 @@ class BaseGuardrails(ABC):
config: RailsConfig

@abstractmethod
def generate(self, *args: Any, **kwargs: Any) -> Any:
def generate(self, *args: Any, options: Optional[Union[dict, GenerationOptions]] = None, **kwargs: Any) -> Any:
"""Generate an LLM response synchronously with guardrails applied."""
...

@abstractmethod
async def generate_async(self, *args: Any, **kwargs: Any) -> Any:
async def generate_async(
self, *args: Any, options: Optional[Union[dict, GenerationOptions]] = None, **kwargs: Any
) -> Any:
"""Generate an LLM response asynchronously with guardrails applied."""
...

Expand Down
13 changes: 8 additions & 5 deletions nemoguardrails/guardrails/actions/content_safety_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,13 @@ def _content_safety_to_rail_result(parsed: object) -> RailResult:
"""
if isinstance(parsed, (list, tuple)):
if parsed and parsed[0] is True:
return RailResult(is_safe=True)
return RailResult(is_safe=True, return_value={"allowed": True, "policy_violations": []})
if parsed and parsed[0] is False:
if len(parsed) > 1:
categories = ", ".join(str(c) for c in parsed[1:])
return RailResult(is_safe=False, reason=f"Safety categories: {categories}")
return RailResult(is_safe=False, reason="Unknown")
violations = [str(c) for c in parsed[1:]]
verdict = {"allowed": False, "policy_violations": violations}
if violations:
return RailResult(
is_safe=False, reason=f"Safety categories: {', '.join(violations)}", return_value=verdict
)
return RailResult(is_safe=False, reason="Unknown", return_value=verdict)
raise RuntimeError(f"Unexpected content safety parse result: {parsed}")
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def _parse_response(self, response: Any) -> RailResult:
raise RuntimeError(f"Jailbreak response missing 'jailbreak' field: {response}")

score = response.get("score", "unknown")
if response["jailbreak"]:
return RailResult(is_safe=False, reason=f"Score: {score}")
return RailResult(is_safe=True, reason=f"Score: {score}")
is_jailbreak = response["jailbreak"]
if is_jailbreak:
return RailResult(is_safe=False, reason=f"Score: {score}", return_value=is_jailbreak)
return RailResult(is_safe=True, reason=f"Score: {score}", return_value=is_jailbreak)
Comment thread
tgasser-nv marked this conversation as resolved.
4 changes: 2 additions & 2 deletions nemoguardrails/guardrails/actions/topic_safety_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,5 @@ async def _get_response(self, model_type: Optional[str], prompt: Any) -> str:

def _parse_response(self, response: Any) -> RailResult:
if response.lower().strip() == "off-topic":
return RailResult(is_safe=False, reason="Topic safety: off-topic")
return RailResult(is_safe=True)
return RailResult(is_safe=False, reason="Topic safety: off-topic", return_value={"on_topic": False})
return RailResult(is_safe=True, return_value={"on_topic": True})
4 changes: 4 additions & 0 deletions nemoguardrails/guardrails/engine_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ def _get_engine(self, name: str, expected_type: type[_EngineT]) -> _EngineT:
raise TypeError(f"Engine '{name}' is {type(engine).__name__}, expected {expected_type.__name__}")
return engine

def provider_name(self, model_type: str) -> str:
"""Return the provider/engine name (e.g. 'nim', 'openai') for a model engine."""
return self._get_engine(model_type, ModelEngine).model_config.engine or "unknown"

async def model_call(self, model_type: str, messages: list[dict], **kwargs: Any) -> LLMResponse:
"""Route a chat completion request to the named model engine.

Expand Down
62 changes: 16 additions & 46 deletions nemoguardrails/guardrails/guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

import logging
import warnings
from typing import Any, AsyncIterator, Callable, List, Optional, Tuple, Type, Union, cast, overload
from typing import Any, AsyncIterator, Callable, List, Optional, Tuple, Type, Union, cast

from typing_extensions import Self

Expand All @@ -38,7 +38,7 @@
from nemoguardrails.logging.explain import ExplainInfo
from nemoguardrails.rails.llm.config import RailsConfig
from nemoguardrails.rails.llm.llmrails import LLMRails
from nemoguardrails.rails.llm.options import GenerationResponse, RailsResult, RailType
from nemoguardrails.rails.llm.options import GenerationOptions, GenerationResponse, RailsResult, RailType
from nemoguardrails.types import LLMModel

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -181,74 +181,44 @@ def passthrough_fn(self, fn: Optional[Callable]) -> None:
llmrails = cast(LLMRails, self.rails_engine)
llmrails.passthrough_fn = fn

@staticmethod
def _convert_to_messages(prompt: str | None = None, messages: LLMMessages | None = None) -> LLMMessages:
"""Return messages in standard format, converting a prompt string if needed.

If messages is provided, returns it as-is.
If prompt is provided, wraps it as [{"role": "user", "content": prompt}].
"""

# Priority: messages first, then prompt
if messages:
return messages

if prompt:
# Convert string prompt to standard format
return [{"role": "user", "content": prompt}]

raise ValueError("Neither prompt nor messages provided for generation")

async def _ensure_started(self) -> None:
"""Lazy initialization: call startup() on first use if not already started."""
if not self._started:
await self.startup()

def generate(
self, prompt: str | None = None, messages: LLMMessages | None = None, **kwargs
self,
prompt: str | None = None,
messages: LLMMessages | None = None,
options: Optional[Union[dict, GenerationOptions]] = None,
**kwargs,
) -> Union[str, dict, GenerationResponse, Tuple[dict, dict]]:
"""Generate an LLM response synchronously with guardrails applied.
Supported in both IORails and LLMRails
"""
return self.rails_engine.generate(prompt=prompt, messages=messages, options=options, **kwargs)
Comment thread
tgasser-nv marked this conversation as resolved.

generate_messages = self._convert_to_messages(prompt, messages)
return self.rails_engine.generate(messages=generate_messages, **kwargs)

@overload
async def generate_async(self, prompt: str | None = None, messages: LLMMessages | None = None, **kwargs) -> str: ...

@overload
async def generate_async(
self, prompt: str | None = None, messages: LLMMessages | None = None, **kwargs
) -> dict: ...

@overload
async def generate_async(
self, prompt: str | None = None, messages: LLMMessages | None = None, **kwargs
) -> GenerationResponse: ...

@overload
async def generate_async(
self, prompt: str | None = None, messages: LLMMessages | None = None, **kwargs
) -> tuple[dict, dict]: ...

async def generate_async(
self, prompt: str | None = None, messages: LLMMessages | None = None, **kwargs
self,
prompt: str | None = None,
messages: LLMMessages | None = None,
options: Optional[Union[dict, GenerationOptions]] = None,
**kwargs,
) -> str | dict | GenerationResponse | tuple[dict, dict]:
"""Generate an LLM response asynchronously with guardrails applied.
Supported by both LLMRails and IORails
"""
await self._ensure_started()

generate_messages = self._convert_to_messages(prompt, messages)
return await self.rails_engine.generate_async(messages=generate_messages, **kwargs)
return await self.rails_engine.generate_async(prompt=prompt, messages=messages, options=options, **kwargs)

def stream_async(
self, prompt: str | None = None, messages: LLMMessages | None = None, **kwargs
) -> AsyncIterator[str | dict]:
"""Generate an LLM response asynchronously with streaming support."""

stream_messages = self._convert_to_messages(prompt, messages)
# TODO: Move prompt/message normalization into IORails when streaming GenerationOptions added
stream_messages = IORails._convert_to_messages(prompt, messages)

async def _with_startup(iterator: AsyncIterator[str | dict]) -> AsyncIterator[str | dict]:
await self._ensure_started()
Expand Down
84 changes: 81 additions & 3 deletions nemoguardrails/guardrails/guardrails_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@

import secrets
from contextvars import ContextVar, Token
from dataclasses import dataclass
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, TypeAlias
from typing import Any, Optional, TypeAlias

from nemoguardrails.types import LLMResponse, UsageInfo

# LLMMessage can contain role/content, plus optional tool_calls / tool_call_id / name; content may be None
LLMMessage: TypeAlias = dict[str, Any]
Expand All @@ -32,13 +34,70 @@ class RailDirection(Enum):
OUTPUT = "Output"


@dataclass(frozen=True, slots=True)
class RailCallRecord:
"""One rail's execution record, carried on RailResult for GenerationLog synthesis.

Captures what a single rail did — its verdict and the (at most one) model call it
made — as engine-neutral data. IORails maps a ``RailCallRecord`` to an
``ActivatedRail`` (with a single synthetic ``ExecutedAction`` and ``LLMCallInfo``);
the raw ``usage``/timing is kept here so this module stays free of the pydantic
``GenerationLog`` types. Tool rails that make no model call leave ``usage`` None.
"""

flow: str
rail_type: str
is_safe: bool
made_call: bool = False
action_name: Optional[str] = None
return_value: Any = None
task: Optional[str] = None
request_id: Optional[str] = None
usage: Optional[UsageInfo] = None
llm_model_name: Optional[str] = None
llm_provider_name: Optional[str] = None
prompt: Optional[str] = None
completion: Optional[str] = None
started_at: Optional[float] = None
finished_at: Optional[float] = None
duration: Optional[float] = None


@dataclass(frozen=True, slots=True)
class RailResult:
"""Result of a rail safety check."""
"""Result of a rail safety check.

``records`` carries the per-rail execution records for every rail that ran in this
check (not just the blocking one), so IORails can synthesize a ``GenerationLog``.
It is empty unless log collection is active. ``return_value`` is the rail's
structured verdict (e.g. ``{"allowed": ..., "policy_violations": [...]}``) when the
action supplies one, used as the log's ``ExecutedAction.return_value``.

``records`` and ``return_value`` are log-capture metadata, not part of the safety
verdict, so they are excluded from equality and hashing (``compare=False``) — two
results with the same ``is_safe``/``reason``/``triggered_rail`` compare equal
regardless of captured log data.
"""

is_safe: bool
reason: str | None = None
triggered_rail: str | None = None
records: tuple[RailCallRecord, ...] = field(default=(), compare=False)
return_value: Any = field(default=None, compare=False)


@dataclass(frozen=True, slots=True)
class TimedLLMResponse:
"""An LLM response paired with wall-clock start/finish timestamps and a monotonic duration.

Returned by IORails' main-model call helper so the sequential and speculative paths both
carry real timing into the generation ``RailCallRecord``.
"""

response: LLMResponse
started_at: float
finished_at: float
duration: float


# Default max character length for truncate(). Used to keep DEBUG log lines short.
Expand Down Expand Up @@ -84,3 +143,22 @@ def truncate(text: object, max_len: int | None = None) -> str:
if len(s) <= limit:
return s
return s[:limit] + "..."


def serialize_prompt(messages: list[dict]) -> str:
"""Render a chat message list to a role-labeled string for GenerationLog's ``prompt``.

Content parity with LLMRails' logged prompt, not byte-for-byte format parity: each
message becomes ``"<role>: <content>"``. Non-content fields present on the message
(``name``, ``tool_call_id``, ``tool_calls``, ``reasoning``) are appended as a compact
``[key=value, ...]`` suffix so tool-call and reasoning-only turns are preserved rather
than dropped. Messages are blank-line separated.
"""
lines = []
for m in messages:
line = f"{m.get('role', '')}: {m.get('content') or ''}"
extras = [f"{key}={m[key]}" for key in ("name", "tool_call_id", "tool_calls", "reasoning") if m.get(key)]
if extras:
line += f" [{', '.join(extras)}]"
lines.append(line)
return "\n\n".join(lines)
Loading