Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
bb0e3d6
feat(testing): queue recorded HTTP responses
Pouyanpi Jul 2, 2026
4b669f6
refactor(library): migrate vendor HTTP actions
Pouyanpi Jul 2, 2026
816f295
refactor(f5): use canonical HTTP client
Pouyanpi Jul 14, 2026
2fdd61c
test(library): inject recorded HTTP clients
Pouyanpi Jul 3, 2026
5c54b43
refactor(library): centralize vendor HTTP client resolution
Pouyanpi Jul 23, 2026
d72e6be
fix(autoalign): remove unreachable factcheck fallback
Pouyanpi Jul 24, 2026
b3a8f4f
test(f5): validate registered request URLs
Pouyanpi Jul 24, 2026
d020283
test(http): share recorded response registrations
Pouyanpi Jul 24, 2026
35feadb
feat(http): support transport TLS configuration
Pouyanpi Jul 2, 2026
c4cbabb
refactor(library): migrate classifier HTTP backends
Pouyanpi Jul 2, 2026
e8a1811
refactor(library): migrate Clavata HTTP client
Pouyanpi Jul 2, 2026
0da7cf6
refactor(http): make TLS configuration transport neutral
Pouyanpi Jul 3, 2026
e8bde33
fix(http): make retry policies explicit
Pouyanpi Jul 3, 2026
96df861
fix(library): preserve rail-specific HTTP retries
Pouyanpi Jul 3, 2026
1c3ca23
refactor(library): centralize specialized HTTP client resolution
Pouyanpi Jul 23, 2026
26e5b5d
test(http): enforce the library transport boundary
Pouyanpi Jul 3, 2026
c761a04
fix(hf-classifier): report ignored local HTTP clients
Pouyanpi Jul 24, 2026
31fe3a6
refactor(clavata): pass optional HTTP client directly
Pouyanpi Jul 24, 2026
bf416c6
refactor(hf-classifier): pass optional HTTP client directly
Pouyanpi Jul 24, 2026
ddf9d7d
test(clavata): avoid real retry delays
Pouyanpi Jul 24, 2026
aa9998f
test(http): keep response registrations test-only
Pouyanpi Jul 24, 2026
6681e30
test(f5): remove aioresponses compatibility shim
Pouyanpi Jul 24, 2026
4eb65f1
refactor(library): simplify vendor HTTP client forwarding
Pouyanpi Jul 30, 2026
dd70d63
fix(ai-defense): distinguish malformed API responses
Pouyanpi Jul 30, 2026
4bcdc84
refactor(clavata): remove obsolete retry utility
Pouyanpi Jul 30, 2026
6f0f7f3
fix(clavata): preserve API error subtypes
Pouyanpi Jul 30, 2026
5803095
test(library): cover migrated vendor HTTP actions
Pouyanpi Jul 30, 2026
b02780f
test(library): cover specialized HTTP migration paths
Pouyanpi Jul 30, 2026
87deec9
fix(library): preserve provider failure policies
Pouyanpi Jul 30, 2026
8a51b01
refactor(autoalign): centralize HTTP response validation
Pouyanpi Jul 30, 2026
b1f460f
refactor(clavata): clarify HTTP client ownership
Pouyanpi Jul 30, 2026
7ddc5ef
test(http): verify transport factory invocation
Pouyanpi Jul 30, 2026
062fa24
docs(f5): document injected HTTP client ownership
Pouyanpi Jul 30, 2026
88969c2
docs(crowdstrike): describe AI Guard failure behavior
Pouyanpi Jul 30, 2026
223a8fc
docs(library): document provider action failure policies
Pouyanpi Jul 30, 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
3 changes: 2 additions & 1 deletion nemoguardrails/http/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from nemoguardrails.http.retry import RetryingHTTPClient, RetryPolicy
from nemoguardrails.http.runtime import create_http_client
from nemoguardrails.http.transport import HttpxHTTPClient
from nemoguardrails.http.types import HTTPRequest, HTTPResponse
from nemoguardrails.http.types import HTTPRequest, HTTPResponse, HTTPTLSConfig

__all__ = [
"ClosableHTTPClient",
Expand All @@ -39,6 +39,7 @@
"HTTPResponseDecodeError",
"HTTPStatusError",
"HTTPTimeoutError",
"HTTPTLSConfig",
"HttpxHTTPClient",
"RetryPolicy",
"RetryingHTTPClient",
Expand Down
6 changes: 5 additions & 1 deletion nemoguardrails/http/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from nemoguardrails.http.client import ClosableHTTPClient
from nemoguardrails.http.retry import RetryingHTTPClient, RetryPolicy
from nemoguardrails.http.transport import HttpxHTTPClient
from nemoguardrails.http.types import HTTPTLSConfig


def create_http_client(
Expand All @@ -29,6 +30,7 @@ def create_http_client(
limits: httpx.Limits | None = None,
retry_policy: RetryPolicy | None = None,
follow_redirects: bool = False,
tls: HTTPTLSConfig | None = None,
) -> ClosableHTTPClient:
"""Create a transport-neutral HTTP client with optional retry behavior.

Expand All @@ -39,6 +41,7 @@ def create_http_client(
retry_policy: Optional policy applied to created or injected clients.
follow_redirects: Whether a client created by this function follows
redirects.
tls: TLS settings for a client created by this function.

Returns:
A closable transport-neutral HTTP client.
Expand All @@ -48,7 +51,7 @@ def create_http_client(
injected client.

When ``httpx_client`` is provided, it remains caller-owned and ``timeout``,
``limits``, and ``follow_redirects`` must retain their defaults.
``limits``, ``follow_redirects``, and ``tls`` must retain their defaults.
``retry_policy`` is still applied when supplied.
"""

Expand All @@ -57,6 +60,7 @@ def create_http_client(
timeout=timeout,
limits=limits,
follow_redirects=follow_redirects,
tls=tls,
)
client: ClosableHTTPClient = transport
if retry_policy is not None:
Expand Down
15 changes: 14 additions & 1 deletion nemoguardrails/http/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

from nemoguardrails.http._url import sanitize_url
from nemoguardrails.http.errors import HTTPConnectionError, HTTPTimeoutError
from nemoguardrails.http.types import HTTPResponse
from nemoguardrails.http.types import HTTPResponse, HTTPTLSConfig

_DEFAULT_TIMEOUT_SECONDS = 30.0

Expand Down Expand Up @@ -51,6 +51,7 @@ def __init__(
timeout: float | None = _DEFAULT_TIMEOUT_SECONDS,
limits: httpx.Limits | None = None,
follow_redirects: bool = False,
tls: HTTPTLSConfig | None = None,
):
"""Initialize the HTTPX adapter.

Expand All @@ -59,22 +60,34 @@ def __init__(
timeout: Default total timeout for owned clients, in seconds.
limits: Connection-pool limits for an owned client.
follow_redirects: Whether an owned client follows redirects.
tls: TLS settings for an owned client.

Raises:
ValueError: If the configured timeout is not positive or owned-client
options are supplied with an injected client.
"""

if client is not None and tls is not None:
raise ValueError("TLS configuration cannot be combined with an injected HTTPX client")
if client is not None and (timeout != _DEFAULT_TIMEOUT_SECONDS or limits is not None or follow_redirects):
raise ValueError("Owned-client options cannot be used with an injected HTTPX client")
if timeout is not None and timeout <= 0:
raise ValueError("HTTP timeout must be greater than zero")
tls_config = tls or HTTPTLSConfig()
verify: bool | str = tls_config.verify
if tls_config.verify and tls_config.ca_bundle is not None:
verify = tls_config.ca_bundle
cert = None
if tls_config.client_certificate is not None and tls_config.client_key is not None:
cert = (tls_config.client_certificate, tls_config.client_key)
self._owns_client = client is None
self._timeout = timeout if self._owns_client else None
self._client = client or httpx.AsyncClient(
timeout=None,
limits=limits or httpx.Limits(max_connections=100, max_keepalive_connections=20),
follow_redirects=follow_redirects,
verify=verify,
cert=cert,
)
self._closed = False

Expand Down
18 changes: 18 additions & 0 deletions nemoguardrails/http/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,24 @@
from nemoguardrails.http.errors import HTTPResponseDecodeError, HTTPStatusError


@dataclass(frozen=True)
class HTTPTLSConfig:
"""Configure TLS verification and optional mutual authentication.

A custom CA bundle applies only when verification is enabled. Client
certificate and key paths must be supplied together.
"""

verify: bool = True
ca_bundle: str | None = None
client_certificate: str | None = None
client_key: str | None = None

def __post_init__(self) -> None:
if bool(self.client_certificate) != bool(self.client_key):
raise ValueError("client_certificate and client_key must be configured together")


@dataclass(frozen=True)
class HTTPRequest:
"""Describe an outbound HTTP request without transport-specific objects.
Expand Down
93 changes: 53 additions & 40 deletions nemoguardrails/library/ai_defense/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,15 @@
import os
from typing import Any, Optional

import httpx

from nemoguardrails import RailsConfig
from nemoguardrails.actions import action
from nemoguardrails.actions.rail_outcome import RailOutcome
from nemoguardrails.http import (
HTTPClient,
HTTPClientError,
HTTPResponseDecodeError,
http_call,
)

log = logging.getLogger(__name__)

Expand All @@ -37,11 +41,20 @@ def _ai_defense_outcome(is_blocked: bool) -> RailOutcome:
return RailOutcome.allow(metadata={"is_blocked": is_blocked})


def _ai_defense_failure_outcome(fail_open: bool, failure: str) -> RailOutcome:
if fail_open:
log.warning("%s, but fail_open=True, allowing content.", failure)
return _ai_defense_outcome(False)
log.warning("%s, fail_open=False, blocking content.", failure)
return _ai_defense_outcome(True)


@action(is_system_action=True)
async def ai_defense_inspect(
config: RailsConfig,
user_prompt: Optional[str] = None,
bot_response: Optional[str] = None,
http_client: Optional[HTTPClient] = None,
**kwargs,
) -> RailOutcome:
# Get configuration with defaults
Expand Down Expand Up @@ -89,44 +102,44 @@ async def ai_defense_inspect(
if metadata:
payload["metadata"] = metadata

async with httpx.AsyncClient() as client:
try:
resp = await client.post(api_endpoint, headers=headers, json=payload, timeout=timeout)
resp.raise_for_status()
data = resp.json()
except (httpx.HTTPStatusError, httpx.TimeoutException, httpx.RequestError) as e:
msg = f"Error calling AI Defense API: {e}"
log.error(msg)
if fail_open:
# Fail open: allow content when API call fails
log.warning("AI Defense API call failed, but fail_open=True, allowing content.")
return _ai_defense_outcome(False)
else:
# Fail closed: block content when API call fails
log.warning("AI Defense API call failed, fail_open=False, blocking content.")
return _ai_defense_outcome(True)

# Compose a consistent return structure for flows
# Handle malformed responses based on fail_open setting
if "is_safe" not in data:
# Malformed response - respect fail_open setting
if fail_open:
log.warning(
"AI Defense API returned malformed response (missing 'is_safe'), but fail_open=True, allowing content."
)
is_blocked = False
else:
log.warning(
"AI Defense API returned malformed response (missing 'is_safe'), fail_open=False, blocking content."
)
is_blocked = True
try:
response = await http_call(
http_client,
"POST",
api_endpoint,
headers=headers,
json=payload,
timeout=timeout,
)
data = response.json()
except HTTPResponseDecodeError as e:
log.error("AI Defense API returned malformed JSON: %s", e)
return _ai_defense_failure_outcome(fail_open, "AI Defense API returned malformed JSON")
except HTTPClientError as e:
log.error("Error calling AI Defense API: %s", e)
return _ai_defense_failure_outcome(fail_open, "AI Defense API call failed")

# Compose a consistent return structure for flows
# Handle malformed responses based on fail_open setting
if "is_safe" not in data:
Comment thread
Pouyanpi marked this conversation as resolved.
# Malformed response - respect fail_open setting
if fail_open:
log.warning(
"AI Defense API returned malformed response (missing 'is_safe'), but fail_open=True, allowing content."
)
is_blocked = False
else:
is_blocked = not bool(data.get("is_safe", False))
log.warning(
"AI Defense API returned malformed response (missing 'is_safe'), fail_open=False, blocking content."
)
is_blocked = True
else:
is_blocked = not bool(data.get("is_safe", False))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

rules = data.get("rules") or []
if is_blocked and rules:
entries = [f"{r.get('rule_name')} ({r.get('classification')})" for r in rules if isinstance(r, dict)]
if entries:
log.debug("AI Defense matched rules: %s", ", ".join(entries))
rules = data.get("rules") or []
if is_blocked and rules:
entries = [f"{r.get('rule_name')} ({r.get('classification')})" for r in rules if isinstance(r, dict)]
if entries:
log.debug("AI Defense matched rules: %s", ", ".join(entries))

return _ai_defense_outcome(is_blocked)
return _ai_defense_outcome(is_blocked)
Loading
Loading