neuraltrust-haystack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- haystack_integrations/components/guardrails/neuraltrust/__init__.py +25 -0
- haystack_integrations/components/guardrails/neuraltrust/_base.py +247 -0
- haystack_integrations/components/guardrails/neuraltrust/_client.py +374 -0
- haystack_integrations/components/guardrails/neuraltrust/_version.py +3 -0
- haystack_integrations/components/guardrails/neuraltrust/chat_guard.py +99 -0
- haystack_integrations/components/guardrails/neuraltrust/errors.py +41 -0
- haystack_integrations/components/guardrails/neuraltrust/guard.py +62 -0
- haystack_integrations/components/guardrails/neuraltrust/py.typed +0 -0
- neuraltrust_haystack-0.1.0.dist-info/METADATA +255 -0
- neuraltrust_haystack-0.1.0.dist-info/RECORD +12 -0
- neuraltrust_haystack-0.1.0.dist-info/WHEEL +4 -0
- neuraltrust_haystack-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""NeuralTrust guardrails for Haystack text and chat pipelines."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .chat_guard import NeuralTrustChatGuard
|
|
5
|
+
from .errors import (
|
|
6
|
+
NeuralTrustAuthenticationError,
|
|
7
|
+
NeuralTrustBlockedError,
|
|
8
|
+
NeuralTrustError,
|
|
9
|
+
NeuralTrustInvalidResponseError,
|
|
10
|
+
NeuralTrustRequestError,
|
|
11
|
+
NeuralTrustUnavailableError,
|
|
12
|
+
)
|
|
13
|
+
from .guard import NeuralTrustGuard
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"NeuralTrustAuthenticationError",
|
|
17
|
+
"NeuralTrustBlockedError",
|
|
18
|
+
"NeuralTrustChatGuard",
|
|
19
|
+
"NeuralTrustError",
|
|
20
|
+
"NeuralTrustGuard",
|
|
21
|
+
"NeuralTrustInvalidResponseError",
|
|
22
|
+
"NeuralTrustRequestError",
|
|
23
|
+
"NeuralTrustUnavailableError",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Configuration, request building and safe transformation shared by the components."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import math
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any, Literal, TypeVar
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from haystack import default_from_dict, default_to_dict
|
|
11
|
+
from haystack.utils import Secret
|
|
12
|
+
|
|
13
|
+
from ._client import TrustGuardClient, validate_json
|
|
14
|
+
from .errors import NeuralTrustBlockedError, NeuralTrustInvalidResponseError
|
|
15
|
+
|
|
16
|
+
_GuardT = TypeVar("_GuardT", bound="NeuralTrustBase")
|
|
17
|
+
_DEFAULT_API_KEY = Secret.from_env_var("TRUSTGUARD_API_KEY")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _api_base(value: str) -> str:
|
|
21
|
+
if not isinstance(value, str):
|
|
22
|
+
raise TypeError("api_base must be a string.")
|
|
23
|
+
if not value or any(char.isspace() or ord(char) < 32 for char in value) or "\\" in value:
|
|
24
|
+
raise ValueError("api_base must be an HTTPS origin or base path without credentials, query or fragment.")
|
|
25
|
+
try:
|
|
26
|
+
parsed = urlsplit(value)
|
|
27
|
+
hostname = parsed.hostname
|
|
28
|
+
port = parsed.port
|
|
29
|
+
if (
|
|
30
|
+
not hostname
|
|
31
|
+
or parsed.username is not None
|
|
32
|
+
or parsed.password is not None
|
|
33
|
+
or parsed.query
|
|
34
|
+
or parsed.fragment
|
|
35
|
+
):
|
|
36
|
+
raise ValueError
|
|
37
|
+
if "?" in value or "#" in value or (port is not None and not 1 <= port <= 65535):
|
|
38
|
+
raise ValueError
|
|
39
|
+
loopback = hostname.lower() == "localhost"
|
|
40
|
+
if not loopback:
|
|
41
|
+
try:
|
|
42
|
+
loopback = ipaddress.ip_address(hostname).is_loopback
|
|
43
|
+
except ValueError:
|
|
44
|
+
pass
|
|
45
|
+
if parsed.scheme != "https" and not (parsed.scheme == "http" and loopback):
|
|
46
|
+
raise ValueError
|
|
47
|
+
except ValueError:
|
|
48
|
+
raise ValueError("api_base requires HTTPS; HTTP is allowed only for loopback hosts.") from None
|
|
49
|
+
return value.rstrip("/")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _optional_id(value: str | None, name: str) -> None:
|
|
53
|
+
if value is not None and (not isinstance(value, str) or not value.strip()):
|
|
54
|
+
raise ValueError(f"{name} must be a nonempty string when provided.")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_text(value: str) -> None:
|
|
58
|
+
if not isinstance(value, str):
|
|
59
|
+
raise TypeError("Guard content must be a string.")
|
|
60
|
+
if not value.strip():
|
|
61
|
+
raise ValueError("Guard content must not be empty or whitespace-only.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def transformed_texts(payload: Any, messages: list[dict[str, Any]]) -> list[str]:
|
|
65
|
+
"""Map only a validated, unambiguous text transform onto the original messages."""
|
|
66
|
+
invalid = "TrustGuard returned a missing, unsupported or ambiguous text transformation."
|
|
67
|
+
if not isinstance(payload, dict):
|
|
68
|
+
raise NeuralTrustInvalidResponseError(invalid)
|
|
69
|
+
if "messages" in payload:
|
|
70
|
+
transformed = payload["messages"]
|
|
71
|
+
if "input" in payload or not isinstance(transformed, list) or len(transformed) != len(messages):
|
|
72
|
+
raise NeuralTrustInvalidResponseError(invalid)
|
|
73
|
+
result: list[str] = []
|
|
74
|
+
for original, replacement in zip(messages, transformed, strict=True):
|
|
75
|
+
if (
|
|
76
|
+
not isinstance(replacement, dict)
|
|
77
|
+
or not set(replacement).issubset({"role", "content", "name"})
|
|
78
|
+
or replacement.get("role") != original["role"]
|
|
79
|
+
or ("name" in replacement and replacement["name"] != original.get("name"))
|
|
80
|
+
):
|
|
81
|
+
raise NeuralTrustInvalidResponseError(invalid)
|
|
82
|
+
text = replacement.get("content")
|
|
83
|
+
if not isinstance(text, str) or not text.strip():
|
|
84
|
+
raise NeuralTrustInvalidResponseError(invalid)
|
|
85
|
+
result.append(text)
|
|
86
|
+
return result
|
|
87
|
+
text = payload.get("input")
|
|
88
|
+
if len(messages) != 1 or not isinstance(text, str) or not text.strip():
|
|
89
|
+
raise NeuralTrustInvalidResponseError(invalid)
|
|
90
|
+
return [text]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class NeuralTrustBase:
|
|
94
|
+
"""Common configuration for the text and chat guards.
|
|
95
|
+
|
|
96
|
+
``api_key`` is a Haystack Secret, resolved only at evaluation time. Use an
|
|
97
|
+
environment-variable Secret for pipeline serialization. ``collector_key`` is
|
|
98
|
+
an optional nonsecret collector selector for service-token authentication.
|
|
99
|
+
Collector API keys do not need a selector.
|
|
100
|
+
|
|
101
|
+
``max_retries`` is the number of additional attempts (0 through 10), for
|
|
102
|
+
connection failures, timeouts and HTTP 429/502/504 only. ``timeout`` is the
|
|
103
|
+
HTTPX timeout in seconds for each network operation, not an overall deadline.
|
|
104
|
+
``on_violation='route'`` omits the content socket on block/ask; all evaluation
|
|
105
|
+
errors still raise. No implicit approval or failure bypass is available.
|
|
106
|
+
|
|
107
|
+
HTTP connections are pooled. Use ``with guard`` for synchronous execution or
|
|
108
|
+
``async with guard`` for async execution, or call ``close()`` / ``aclose()``
|
|
109
|
+
explicitly. Each event loop owns its async pool and must close it before
|
|
110
|
+
stopping. A guard can mix sync and async calls and can be reused after close.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
api_key: Secret = _DEFAULT_API_KEY,
|
|
117
|
+
api_base: str = "https://trustguard.neuraltrust.ai",
|
|
118
|
+
direction: Literal["input", "output"] = "input",
|
|
119
|
+
on_violation: Literal["raise", "route"] = "raise",
|
|
120
|
+
timeout: float = 5.0,
|
|
121
|
+
max_retries: int = 2,
|
|
122
|
+
collector_key: str | None = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
if not isinstance(api_key, Secret):
|
|
125
|
+
raise TypeError("api_key must be a Haystack Secret.")
|
|
126
|
+
if direction not in ("input", "output"):
|
|
127
|
+
raise ValueError("direction must be 'input' or 'output'.")
|
|
128
|
+
if on_violation not in ("raise", "route"):
|
|
129
|
+
raise ValueError("on_violation must be 'raise' or 'route'.")
|
|
130
|
+
if (
|
|
131
|
+
isinstance(timeout, bool)
|
|
132
|
+
or not isinstance(timeout, (int, float))
|
|
133
|
+
or not math.isfinite(timeout)
|
|
134
|
+
or timeout <= 0
|
|
135
|
+
):
|
|
136
|
+
raise ValueError("timeout must be a finite positive number of seconds.")
|
|
137
|
+
if isinstance(max_retries, bool) or not isinstance(max_retries, int) or not 0 <= max_retries <= 10:
|
|
138
|
+
raise ValueError("max_retries must be an integer between 0 and 10.")
|
|
139
|
+
_optional_id(collector_key, "collector_key")
|
|
140
|
+
self.api_key = api_key
|
|
141
|
+
self.api_base = _api_base(api_base)
|
|
142
|
+
self.direction = direction
|
|
143
|
+
self.on_violation = on_violation
|
|
144
|
+
self.timeout = float(timeout)
|
|
145
|
+
self.max_retries = max_retries
|
|
146
|
+
self.collector_key = collector_key
|
|
147
|
+
self._client = TrustGuardClient(
|
|
148
|
+
api_key=self.api_key,
|
|
149
|
+
endpoint=f"{self.api_base}/v1/evaluate",
|
|
150
|
+
timeout=self.timeout,
|
|
151
|
+
max_retries=self.max_retries,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def close(self) -> None:
|
|
155
|
+
"""Drain and close the synchronous pool. Later evaluations may reopen it."""
|
|
156
|
+
self._client.close()
|
|
157
|
+
|
|
158
|
+
async def aclose(self) -> None:
|
|
159
|
+
"""Drain this event loop's async pool and the synchronous pool.
|
|
160
|
+
|
|
161
|
+
Call before the owning loop stops. Pools in other loops are unaffected.
|
|
162
|
+
Cancellation does not interrupt cleanup; evaluations started after
|
|
163
|
+
cleanup begins may create a new pool.
|
|
164
|
+
"""
|
|
165
|
+
await self._client.aclose()
|
|
166
|
+
|
|
167
|
+
def __enter__(self: _GuardT) -> _GuardT:
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
def __exit__(
|
|
171
|
+
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
|
|
172
|
+
) -> None:
|
|
173
|
+
self.close()
|
|
174
|
+
|
|
175
|
+
async def __aenter__(self: _GuardT) -> _GuardT:
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
async def __aexit__(
|
|
179
|
+
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
|
|
180
|
+
) -> None:
|
|
181
|
+
await self.aclose()
|
|
182
|
+
|
|
183
|
+
def to_dict(self) -> dict[str, Any]:
|
|
184
|
+
"""Serialize configuration without resolving or serializing the API credential."""
|
|
185
|
+
return default_to_dict(
|
|
186
|
+
self,
|
|
187
|
+
api_key=self.api_key.to_dict(),
|
|
188
|
+
api_base=self.api_base,
|
|
189
|
+
direction=self.direction,
|
|
190
|
+
on_violation=self.on_violation,
|
|
191
|
+
timeout=self.timeout,
|
|
192
|
+
max_retries=self.max_retries,
|
|
193
|
+
collector_key=self.collector_key,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def from_dict(cls: type[_GuardT], data: dict[str, Any]) -> _GuardT:
|
|
198
|
+
"""Deserialize a component without modifying the caller's configuration dictionary."""
|
|
199
|
+
copied = deepcopy(data)
|
|
200
|
+
params = copied.get("init_parameters", {})
|
|
201
|
+
if isinstance(params.get("api_key"), dict):
|
|
202
|
+
params["api_key"] = Secret.from_dict(params["api_key"])
|
|
203
|
+
return default_from_dict(cls, copied)
|
|
204
|
+
|
|
205
|
+
def _body(
|
|
206
|
+
self,
|
|
207
|
+
messages: list[dict[str, Any]],
|
|
208
|
+
*,
|
|
209
|
+
session_id: str | None,
|
|
210
|
+
consumer_id: str | None,
|
|
211
|
+
attributes: dict[str, Any] | None,
|
|
212
|
+
) -> dict[str, Any]:
|
|
213
|
+
_optional_id(session_id, "session_id")
|
|
214
|
+
_optional_id(consumer_id, "consumer_id")
|
|
215
|
+
if attributes is not None and not isinstance(attributes, dict):
|
|
216
|
+
raise TypeError("attributes must be a JSON-compatible dictionary.")
|
|
217
|
+
try:
|
|
218
|
+
validate_json(attributes)
|
|
219
|
+
except (ValueError, RecursionError):
|
|
220
|
+
raise ValueError(
|
|
221
|
+
"attributes must contain JSON-compatible values with string keys and finite numbers."
|
|
222
|
+
) from None
|
|
223
|
+
copied_attributes = deepcopy(attributes) if attributes is not None else {}
|
|
224
|
+
source = copied_attributes.setdefault("source", {})
|
|
225
|
+
if not isinstance(source, dict):
|
|
226
|
+
raise ValueError("attributes.source must be a JSON-compatible dictionary.")
|
|
227
|
+
source.setdefault("application", "haystack")
|
|
228
|
+
body: dict[str, Any] = {
|
|
229
|
+
"payload": {"messages": messages},
|
|
230
|
+
"direction": self.direction,
|
|
231
|
+
"protocol": "llm",
|
|
232
|
+
"attributes": copied_attributes,
|
|
233
|
+
}
|
|
234
|
+
if session_id is not None:
|
|
235
|
+
body["session_id"] = session_id
|
|
236
|
+
if consumer_id is not None:
|
|
237
|
+
body["consumer_id"] = consumer_id
|
|
238
|
+
if self.collector_key is not None:
|
|
239
|
+
body["collector_key"] = self.collector_key
|
|
240
|
+
return body
|
|
241
|
+
|
|
242
|
+
def _is_blocked(self, verdict: dict[str, Any]) -> bool:
|
|
243
|
+
if verdict["status"] not in ("block", "ask"):
|
|
244
|
+
return False
|
|
245
|
+
if self.on_violation == "raise":
|
|
246
|
+
raise NeuralTrustBlockedError(verdict)
|
|
247
|
+
return True
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Bounded, fail-closed synchronous and asynchronous TrustGuard transport."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
import ssl
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import AsyncIterator, Iterator
|
|
11
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
12
|
+
from copy import deepcopy
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from email.utils import parsedate_to_datetime
|
|
15
|
+
from functools import partial
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from haystack.utils import Secret
|
|
20
|
+
|
|
21
|
+
from ._version import __version__
|
|
22
|
+
from .errors import (
|
|
23
|
+
NeuralTrustAuthenticationError,
|
|
24
|
+
NeuralTrustInvalidResponseError,
|
|
25
|
+
NeuralTrustRequestError,
|
|
26
|
+
NeuralTrustUnavailableError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_RETRY_STATUSES = frozenset({429, 502, 504})
|
|
30
|
+
_STATUSES = frozenset({"allow", "report", "transform", "ask", "block"})
|
|
31
|
+
_CORRELATION_ID = re.compile(r"[A-Za-z0-9._:-]{1,256}\Z")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
35
|
+
result: dict[str, Any] = {}
|
|
36
|
+
for key, value in pairs:
|
|
37
|
+
if key in result:
|
|
38
|
+
raise ValueError("Duplicate JSON field")
|
|
39
|
+
result[key] = value
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _invalid_constant(_: str) -> None:
|
|
44
|
+
raise ValueError("Non-finite JSON number")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_json(value: Any) -> None:
|
|
48
|
+
"""Validate JSON without silently coercing Python values or dictionary keys."""
|
|
49
|
+
if value is None or isinstance(value, (str, bool, int)):
|
|
50
|
+
return
|
|
51
|
+
if isinstance(value, float) and math.isfinite(value):
|
|
52
|
+
return
|
|
53
|
+
if isinstance(value, list):
|
|
54
|
+
for item in value:
|
|
55
|
+
validate_json(item)
|
|
56
|
+
return
|
|
57
|
+
if isinstance(value, dict) and all(isinstance(key, str) for key in value):
|
|
58
|
+
for item in value.values():
|
|
59
|
+
validate_json(item)
|
|
60
|
+
return
|
|
61
|
+
raise ValueError("Expected JSON-compatible values with string object keys and finite numbers.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_response(response: httpx.Response) -> tuple[dict[str, Any], Any]:
|
|
65
|
+
"""Return an allowlisted verdict and an untrusted transform for later validation."""
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(response.content, parse_constant=_invalid_constant, object_pairs_hook=_unique_object)
|
|
68
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
69
|
+
raise NeuralTrustInvalidResponseError("TrustGuard returned invalid JSON.") from None
|
|
70
|
+
if not isinstance(data, dict) or not isinstance(data.get("status"), str):
|
|
71
|
+
raise NeuralTrustInvalidResponseError("TrustGuard returned a malformed verdict.")
|
|
72
|
+
status = data["status"].strip().lower()
|
|
73
|
+
if status not in _STATUSES:
|
|
74
|
+
raise NeuralTrustInvalidResponseError("TrustGuard returned an unsupported verdict status.")
|
|
75
|
+
verdict: dict[str, Any] = {"status": status}
|
|
76
|
+
if "findings" in data:
|
|
77
|
+
findings = data["findings"]
|
|
78
|
+
if not isinstance(findings, list) or not all(isinstance(finding, dict) for finding in findings):
|
|
79
|
+
raise NeuralTrustInvalidResponseError("TrustGuard returned malformed findings.")
|
|
80
|
+
try:
|
|
81
|
+
validate_json(findings)
|
|
82
|
+
except (ValueError, RecursionError):
|
|
83
|
+
raise NeuralTrustInvalidResponseError("TrustGuard returned malformed findings.") from None
|
|
84
|
+
verdict["findings"] = deepcopy(findings)
|
|
85
|
+
for key in ("trace_id", "request_id"):
|
|
86
|
+
if key in data:
|
|
87
|
+
if not isinstance(data[key], str) or _CORRELATION_ID.fullmatch(data[key]) is None:
|
|
88
|
+
raise NeuralTrustInvalidResponseError("TrustGuard returned a malformed correlation ID.")
|
|
89
|
+
verdict[key] = data[key]
|
|
90
|
+
return verdict, data.get("transformed_payload")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _retry_delay(attempt: int, response: httpx.Response | None = None) -> float:
|
|
94
|
+
if response is not None and (header := response.headers.get("Retry-After")):
|
|
95
|
+
try:
|
|
96
|
+
delay = float(header)
|
|
97
|
+
except ValueError:
|
|
98
|
+
try:
|
|
99
|
+
retry_at = parsedate_to_datetime(header)
|
|
100
|
+
if retry_at.tzinfo is None:
|
|
101
|
+
retry_at = retry_at.replace(tzinfo=timezone.utc)
|
|
102
|
+
delay = (retry_at - datetime.now(timezone.utc)).total_seconds()
|
|
103
|
+
except (ValueError, TypeError, OverflowError):
|
|
104
|
+
delay = -1.0
|
|
105
|
+
if math.isfinite(delay) and delay >= 0:
|
|
106
|
+
return min(delay, 5.0)
|
|
107
|
+
return float(min(0.25 * 2**attempt, 2.0))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _tls_failure(error: BaseException) -> bool:
|
|
111
|
+
current: BaseException | None = error
|
|
112
|
+
visited: set[int] = set()
|
|
113
|
+
while current is not None and id(current) not in visited:
|
|
114
|
+
visited.add(id(current))
|
|
115
|
+
if isinstance(current, ssl.SSLError):
|
|
116
|
+
return True
|
|
117
|
+
if any(hint in str(current).lower() for hint in ("certificate", "ssl", "tls")):
|
|
118
|
+
return True
|
|
119
|
+
current = current.__cause__ or current.__context__
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _check_status(response: httpx.Response) -> bool:
|
|
124
|
+
"""Return whether to retry, otherwise accept 200 or raise a sanitized error."""
|
|
125
|
+
status_code = response.status_code
|
|
126
|
+
if status_code == 200:
|
|
127
|
+
return False
|
|
128
|
+
if status_code in (401, 403):
|
|
129
|
+
raise NeuralTrustAuthenticationError(
|
|
130
|
+
"TrustGuard authentication or authorization failed.", status_code=status_code
|
|
131
|
+
)
|
|
132
|
+
if status_code in _RETRY_STATUSES:
|
|
133
|
+
return True
|
|
134
|
+
raise NeuralTrustRequestError("TrustGuard rejected the evaluation request.", status_code=status_code)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _SyncPool:
|
|
138
|
+
"""A client generation that can drain independently of a replacement pool."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, timeout: float) -> None:
|
|
141
|
+
self.client = httpx.Client(timeout=timeout, follow_redirects=False)
|
|
142
|
+
self.condition = threading.Condition()
|
|
143
|
+
self.active = 0
|
|
144
|
+
|
|
145
|
+
def close(self) -> None:
|
|
146
|
+
with self.condition:
|
|
147
|
+
self.condition.wait_for(lambda: self.active == 0)
|
|
148
|
+
if not self.client.is_closed:
|
|
149
|
+
self.client.close()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def _new_async_client(timeout: float) -> httpx.AsyncClient:
|
|
153
|
+
# Client construction loads TLS certificates synchronously. An executor
|
|
154
|
+
# future (rather than a second Task) survives event-loop shutdown cancellation
|
|
155
|
+
# long enough to dispose of an unused client, including a late worker result.
|
|
156
|
+
pending = asyncio.get_running_loop().run_in_executor(
|
|
157
|
+
None, partial(httpx.AsyncClient, timeout=timeout, follow_redirects=False)
|
|
158
|
+
)
|
|
159
|
+
try:
|
|
160
|
+
return await asyncio.shield(pending)
|
|
161
|
+
except asyncio.CancelledError:
|
|
162
|
+
while not pending.done():
|
|
163
|
+
try:
|
|
164
|
+
await asyncio.shield(pending)
|
|
165
|
+
except asyncio.CancelledError:
|
|
166
|
+
continue
|
|
167
|
+
if not pending.cancelled() and pending.exception() is None:
|
|
168
|
+
await pending.result().aclose()
|
|
169
|
+
raise
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _AsyncPool:
|
|
173
|
+
"""All state and network operations belong to the creating event loop."""
|
|
174
|
+
|
|
175
|
+
def __init__(self, timeout: float) -> None:
|
|
176
|
+
self.initialization = asyncio.create_task(_new_async_client(timeout))
|
|
177
|
+
# Retrieve failures even when all callers were cancelled before setup
|
|
178
|
+
# completed; the exception is still raised to every awaiting evaluation.
|
|
179
|
+
self.initialization.add_done_callback(lambda task: None if task.cancelled() else task.exception())
|
|
180
|
+
self.active = 0
|
|
181
|
+
self.idle = asyncio.Event()
|
|
182
|
+
self.idle.set()
|
|
183
|
+
|
|
184
|
+
async def close(self) -> None:
|
|
185
|
+
await self.idle.wait()
|
|
186
|
+
try:
|
|
187
|
+
client = await asyncio.shield(self.initialization)
|
|
188
|
+
except Exception:
|
|
189
|
+
return # Construction failed, so there is no client to dispose of.
|
|
190
|
+
await client.aclose()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class TrustGuardClient:
|
|
194
|
+
"""Lazy, reusable HTTP pools; async pools are owned by individual event loops.
|
|
195
|
+
|
|
196
|
+
``close()`` drains the synchronous pool. ``aclose()`` drains the current
|
|
197
|
+
event loop's async pool and the synchronous pool. Close async pools in their
|
|
198
|
+
owning loop before stopping it. Evaluations started after cleanup begins
|
|
199
|
+
may create a fresh pool; cleanup never closes another loop's connections.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(self, *, api_key: Secret, endpoint: str, timeout: float, max_retries: int) -> None:
|
|
203
|
+
self.api_key = api_key
|
|
204
|
+
self.endpoint = endpoint
|
|
205
|
+
self.timeout = timeout
|
|
206
|
+
self.max_retries = max_retries
|
|
207
|
+
self._sync_lock = threading.Lock()
|
|
208
|
+
self._sync_pool: _SyncPool | None = None
|
|
209
|
+
self._sync_closing: set[_SyncPool] = set()
|
|
210
|
+
self._async_lock = threading.Lock()
|
|
211
|
+
self._async_pools: dict[asyncio.AbstractEventLoop, _AsyncPool] = {}
|
|
212
|
+
self._async_closing: dict[asyncio.AbstractEventLoop, set[asyncio.Task[None]]] = {}
|
|
213
|
+
|
|
214
|
+
def __deepcopy__(self, memo: dict[int, Any]) -> "TrustGuardClient":
|
|
215
|
+
# Haystack may deepcopy components. HTTP pools, locks and event-loop
|
|
216
|
+
# ownership must never cross into the copied component.
|
|
217
|
+
copied = type(self)(
|
|
218
|
+
api_key=deepcopy(self.api_key, memo),
|
|
219
|
+
endpoint=self.endpoint,
|
|
220
|
+
timeout=self.timeout,
|
|
221
|
+
max_retries=self.max_retries,
|
|
222
|
+
)
|
|
223
|
+
memo[id(self)] = copied
|
|
224
|
+
return copied
|
|
225
|
+
|
|
226
|
+
@contextmanager
|
|
227
|
+
def _sync_http(self) -> Iterator[httpx.Client]:
|
|
228
|
+
with self._sync_lock:
|
|
229
|
+
if self._sync_pool is None:
|
|
230
|
+
try:
|
|
231
|
+
self._sync_pool = _SyncPool(self.timeout)
|
|
232
|
+
except Exception:
|
|
233
|
+
raise NeuralTrustRequestError("TrustGuard transport initialization failed.") from None
|
|
234
|
+
pool = self._sync_pool
|
|
235
|
+
with pool.condition:
|
|
236
|
+
pool.active += 1
|
|
237
|
+
try:
|
|
238
|
+
yield pool.client
|
|
239
|
+
finally:
|
|
240
|
+
with pool.condition:
|
|
241
|
+
pool.active -= 1
|
|
242
|
+
pool.condition.notify_all()
|
|
243
|
+
|
|
244
|
+
@asynccontextmanager
|
|
245
|
+
async def _async_http(self) -> AsyncIterator[httpx.AsyncClient]:
|
|
246
|
+
loop = asyncio.get_running_loop()
|
|
247
|
+
with self._async_lock:
|
|
248
|
+
pool = self._async_pools.get(loop)
|
|
249
|
+
if pool is None:
|
|
250
|
+
pool = _AsyncPool(self.timeout)
|
|
251
|
+
self._async_pools[loop] = pool
|
|
252
|
+
pool.active += 1
|
|
253
|
+
pool.idle.clear()
|
|
254
|
+
try:
|
|
255
|
+
try:
|
|
256
|
+
client = await asyncio.shield(pool.initialization)
|
|
257
|
+
except Exception:
|
|
258
|
+
with self._async_lock:
|
|
259
|
+
if self._async_pools.get(loop) is pool:
|
|
260
|
+
del self._async_pools[loop]
|
|
261
|
+
raise NeuralTrustRequestError("TrustGuard transport initialization failed.") from None
|
|
262
|
+
yield client
|
|
263
|
+
finally:
|
|
264
|
+
pool.active -= 1
|
|
265
|
+
if not pool.active:
|
|
266
|
+
pool.idle.set()
|
|
267
|
+
|
|
268
|
+
def close(self) -> None:
|
|
269
|
+
"""Drain sync evaluations and close their pool; later calls may reopen it."""
|
|
270
|
+
with self._sync_lock:
|
|
271
|
+
if self._sync_pool is not None:
|
|
272
|
+
self._sync_closing.add(self._sync_pool)
|
|
273
|
+
self._sync_pool = None
|
|
274
|
+
pools = tuple(self._sync_closing)
|
|
275
|
+
for pool in pools:
|
|
276
|
+
pool.close()
|
|
277
|
+
with self._sync_lock:
|
|
278
|
+
self._sync_closing.discard(pool)
|
|
279
|
+
|
|
280
|
+
def _finished_close(self, loop: asyncio.AbstractEventLoop, task: asyncio.Task[None]) -> None:
|
|
281
|
+
with self._async_lock:
|
|
282
|
+
pending = self._async_closing.get(loop)
|
|
283
|
+
if pending is not None:
|
|
284
|
+
pending.discard(task)
|
|
285
|
+
if not pending:
|
|
286
|
+
del self._async_closing[loop]
|
|
287
|
+
if not task.cancelled():
|
|
288
|
+
task.exception()
|
|
289
|
+
|
|
290
|
+
async def aclose(self) -> None:
|
|
291
|
+
"""Drain this loop's async pool and the sync pool without blocking the loop.
|
|
292
|
+
|
|
293
|
+
Cancellation of the caller does not cancel cleanup. Another ``aclose``
|
|
294
|
+
call can await cleanup that is still running.
|
|
295
|
+
"""
|
|
296
|
+
loop = asyncio.get_running_loop()
|
|
297
|
+
with self._async_lock:
|
|
298
|
+
pool = self._async_pools.pop(loop, None)
|
|
299
|
+
pending = self._async_closing.setdefault(loop, set())
|
|
300
|
+
if pool is not None:
|
|
301
|
+
task = asyncio.create_task(pool.close())
|
|
302
|
+
pending.add(task)
|
|
303
|
+
task.add_done_callback(partial(self._finished_close, loop))
|
|
304
|
+
sync_close = asyncio.create_task(asyncio.to_thread(self.close))
|
|
305
|
+
pending.add(sync_close)
|
|
306
|
+
sync_close.add_done_callback(partial(self._finished_close, loop))
|
|
307
|
+
tasks = tuple(pending)
|
|
308
|
+
for task in tasks:
|
|
309
|
+
try:
|
|
310
|
+
await asyncio.shield(task)
|
|
311
|
+
finally:
|
|
312
|
+
if task.done():
|
|
313
|
+
self._finished_close(loop, task)
|
|
314
|
+
|
|
315
|
+
def _headers(self) -> dict[str, str]:
|
|
316
|
+
try:
|
|
317
|
+
token = self.api_key.resolve_value()
|
|
318
|
+
except Exception:
|
|
319
|
+
raise NeuralTrustAuthenticationError("The TrustGuard API credential could not be resolved.") from None
|
|
320
|
+
if not isinstance(token, str) or not token or any(ord(char) < 33 or ord(char) > 126 for char in token):
|
|
321
|
+
raise NeuralTrustAuthenticationError("The TrustGuard API credential is missing or invalid.")
|
|
322
|
+
return {
|
|
323
|
+
"Authorization": f"Bearer {token}",
|
|
324
|
+
"Content-Type": "application/json",
|
|
325
|
+
"User-Agent": f"neuraltrust-haystack/{__version__}",
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
def evaluate(self, body: dict[str, Any]) -> tuple[dict[str, Any], Any]:
|
|
329
|
+
headers = self._headers()
|
|
330
|
+
with self._sync_http() as client:
|
|
331
|
+
for attempt in range(self.max_retries + 1):
|
|
332
|
+
response = None
|
|
333
|
+
try:
|
|
334
|
+
response = client.post(self.endpoint, json=body, headers=headers)
|
|
335
|
+
except (httpx.TimeoutException, httpx.ConnectError) as error:
|
|
336
|
+
if _tls_failure(error):
|
|
337
|
+
raise NeuralTrustRequestError("TrustGuard TLS verification failed.") from None
|
|
338
|
+
if attempt == self.max_retries:
|
|
339
|
+
raise NeuralTrustUnavailableError("TrustGuard evaluation is unavailable.") from None
|
|
340
|
+
except httpx.RequestError:
|
|
341
|
+
raise NeuralTrustRequestError("TrustGuard evaluation transport failed.") from None
|
|
342
|
+
else:
|
|
343
|
+
if not _check_status(response):
|
|
344
|
+
return parse_response(response)
|
|
345
|
+
if attempt == self.max_retries:
|
|
346
|
+
raise NeuralTrustUnavailableError(
|
|
347
|
+
"TrustGuard evaluation is unavailable.", status_code=response.status_code
|
|
348
|
+
)
|
|
349
|
+
time.sleep(_retry_delay(attempt, response))
|
|
350
|
+
raise AssertionError("Unreachable retry state")
|
|
351
|
+
|
|
352
|
+
async def evaluate_async(self, body: dict[str, Any]) -> tuple[dict[str, Any], Any]:
|
|
353
|
+
headers = self._headers()
|
|
354
|
+
async with self._async_http() as client:
|
|
355
|
+
for attempt in range(self.max_retries + 1):
|
|
356
|
+
response = None
|
|
357
|
+
try:
|
|
358
|
+
response = await client.post(self.endpoint, json=body, headers=headers)
|
|
359
|
+
except (httpx.TimeoutException, httpx.ConnectError) as error:
|
|
360
|
+
if _tls_failure(error):
|
|
361
|
+
raise NeuralTrustRequestError("TrustGuard TLS verification failed.") from None
|
|
362
|
+
if attempt == self.max_retries:
|
|
363
|
+
raise NeuralTrustUnavailableError("TrustGuard evaluation is unavailable.") from None
|
|
364
|
+
except httpx.RequestError:
|
|
365
|
+
raise NeuralTrustRequestError("TrustGuard evaluation transport failed.") from None
|
|
366
|
+
else:
|
|
367
|
+
if not _check_status(response):
|
|
368
|
+
return parse_response(response)
|
|
369
|
+
if attempt == self.max_retries:
|
|
370
|
+
raise NeuralTrustUnavailableError(
|
|
371
|
+
"TrustGuard evaluation is unavailable.", status_code=response.status_code
|
|
372
|
+
)
|
|
373
|
+
await asyncio.sleep(_retry_delay(attempt, response))
|
|
374
|
+
raise AssertionError("Unreachable retry state")
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Haystack component for unambiguous text-only ChatMessage conversations."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from haystack import component
|
|
7
|
+
from haystack.dataclasses import ChatMessage, ChatRole
|
|
8
|
+
|
|
9
|
+
from ._base import NeuralTrustBase, transformed_texts, validate_text
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _prepare_messages(messages: list[ChatMessage]) -> tuple[list[ChatMessage], list[dict[str, Any]]]:
|
|
13
|
+
if not isinstance(messages, list):
|
|
14
|
+
raise TypeError("messages must be a list of Haystack ChatMessage objects.")
|
|
15
|
+
# Derive the request and result from the same snapshot, even if another
|
|
16
|
+
# pipeline branch modifies the caller's list or messages concurrently.
|
|
17
|
+
originals = deepcopy(messages)
|
|
18
|
+
if not originals:
|
|
19
|
+
raise ValueError("messages must not be empty.")
|
|
20
|
+
payload: list[dict[str, Any]] = []
|
|
21
|
+
for message in originals:
|
|
22
|
+
if not isinstance(message, ChatMessage):
|
|
23
|
+
raise TypeError("messages must contain only Haystack ChatMessage objects.")
|
|
24
|
+
if (
|
|
25
|
+
not isinstance(message.role, ChatRole)
|
|
26
|
+
or message.role not in (ChatRole.SYSTEM, ChatRole.USER, ChatRole.ASSISTANT)
|
|
27
|
+
or len(message) != 1
|
|
28
|
+
or len(message.texts) != 1
|
|
29
|
+
):
|
|
30
|
+
raise ValueError(
|
|
31
|
+
"Each message must have a system, user or assistant role and exactly one text content part."
|
|
32
|
+
)
|
|
33
|
+
text = message.texts[0]
|
|
34
|
+
validate_text(text)
|
|
35
|
+
item: dict[str, Any] = {"role": message.role.value, "content": text}
|
|
36
|
+
if message.name is not None:
|
|
37
|
+
if not isinstance(message.name, str) or not message.name.strip():
|
|
38
|
+
raise ValueError("Message names must be nonempty strings when provided.")
|
|
39
|
+
item["name"] = message.name
|
|
40
|
+
payload.append(item)
|
|
41
|
+
return originals, payload
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@component
|
|
45
|
+
class NeuralTrustChatGuard(NeuralTrustBase):
|
|
46
|
+
"""Evaluate text-only Haystack messages while preserving order, names and metadata.
|
|
47
|
+
|
|
48
|
+
Every message must contain exactly one nonempty text part. Tools, reasoning,
|
|
49
|
+
files, images, audio and multiple content parts are rejected before evaluation.
|
|
50
|
+
Connect the ``messages`` output to the next component. Block and ask raise by
|
|
51
|
+
default; ``on_violation='route'`` emits only ``verdict`` for these statuses.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
@component.output_types(messages=list[ChatMessage], verdict=dict[str, Any])
|
|
55
|
+
def run(
|
|
56
|
+
self,
|
|
57
|
+
messages: list[ChatMessage],
|
|
58
|
+
*,
|
|
59
|
+
session_id: str | None = None,
|
|
60
|
+
consumer_id: str | None = None,
|
|
61
|
+
attributes: dict[str, Any] | None = None,
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
"""Evaluate a conversation and return independent copies containing only safe text."""
|
|
64
|
+
originals, payload = _prepare_messages(messages)
|
|
65
|
+
body = self._body(payload, session_id=session_id, consumer_id=consumer_id, attributes=attributes)
|
|
66
|
+
verdict, transformed = self._client.evaluate(body)
|
|
67
|
+
return self._result(originals, payload, verdict, transformed)
|
|
68
|
+
|
|
69
|
+
@component.output_types(messages=list[ChatMessage], verdict=dict[str, Any])
|
|
70
|
+
async def run_async(
|
|
71
|
+
self,
|
|
72
|
+
messages: list[ChatMessage],
|
|
73
|
+
*,
|
|
74
|
+
session_id: str | None = None,
|
|
75
|
+
consumer_id: str | None = None,
|
|
76
|
+
attributes: dict[str, Any] | None = None,
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
"""Evaluate text-only messages using native async HTTP with the same enforcement as run."""
|
|
79
|
+
originals, payload = _prepare_messages(messages)
|
|
80
|
+
body = self._body(payload, session_id=session_id, consumer_id=consumer_id, attributes=attributes)
|
|
81
|
+
verdict, transformed = await self._client.evaluate_async(body)
|
|
82
|
+
return self._result(originals, payload, verdict, transformed)
|
|
83
|
+
|
|
84
|
+
def _result(
|
|
85
|
+
self,
|
|
86
|
+
originals: list[ChatMessage],
|
|
87
|
+
payload: list[dict[str, Any]],
|
|
88
|
+
verdict: dict[str, Any],
|
|
89
|
+
transformed: Any,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
if self._is_blocked(verdict):
|
|
92
|
+
return {"verdict": verdict}
|
|
93
|
+
if verdict["status"] == "transform":
|
|
94
|
+
texts = transformed_texts(transformed, payload)
|
|
95
|
+
for index, (message, text) in enumerate(zip(originals, texts, strict=True)):
|
|
96
|
+
serialized = deepcopy(message.to_dict())
|
|
97
|
+
serialized["content"] = [{"text": text}]
|
|
98
|
+
originals[index] = ChatMessage.from_dict(serialized)
|
|
99
|
+
return {"messages": originals, "verdict": verdict}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Sanitized errors raised by the NeuralTrust components."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class NeuralTrustError(RuntimeError):
|
|
8
|
+
"""Base error; request bodies, credentials and raw server errors are never included."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
self.status_code = status_code
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NeuralTrustAuthenticationError(NeuralTrustError):
|
|
16
|
+
"""The API credential is missing, invalid, or unauthorized."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NeuralTrustUnavailableError(NeuralTrustError):
|
|
20
|
+
"""A retryable evaluation failure exhausted the configured retry budget."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NeuralTrustRequestError(NeuralTrustError):
|
|
24
|
+
"""TrustGuard rejected the request or a non-retryable transport failure occurred."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NeuralTrustInvalidResponseError(NeuralTrustError):
|
|
28
|
+
"""The response or transformation cannot be safely interpreted."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NeuralTrustBlockedError(NeuralTrustError):
|
|
32
|
+
"""Evaluation blocked content or requested approval that this component cannot grant.
|
|
33
|
+
|
|
34
|
+
``status`` preserves ``block`` or ``ask``. ``verdict`` contains only that status
|
|
35
|
+
and validated correlation IDs, never findings, original content or transforms.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, verdict: dict[str, Any]) -> None:
|
|
39
|
+
self.status = verdict["status"]
|
|
40
|
+
self.verdict = deepcopy({key: verdict[key] for key in ("status", "trace_id", "request_id") if key in verdict})
|
|
41
|
+
super().__init__(f"TrustGuard stopped content with status '{self.status}'.")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Haystack component for evaluating individual text inputs and outputs."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from haystack import component
|
|
6
|
+
|
|
7
|
+
from ._base import NeuralTrustBase, transformed_texts, validate_text
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@component
|
|
11
|
+
class NeuralTrustGuard(NeuralTrustBase):
|
|
12
|
+
"""Evaluate a text string with the policy attached to a TrustGuard collector.
|
|
13
|
+
|
|
14
|
+
Connect ``text`` to downstream components so only allowed or validated
|
|
15
|
+
transformed content proceeds. The ``verdict`` output contains the status,
|
|
16
|
+
findings when supplied, and correlation IDs. With ``on_violation='route'``,
|
|
17
|
+
block and ask emit only ``verdict``, without a ``text`` output.
|
|
18
|
+
|
|
19
|
+
Set ``direction='output'`` when guarding a model response; its policy phase
|
|
20
|
+
and request role then become output and assistant respectively.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@component.output_types(text=str, verdict=dict[str, Any])
|
|
24
|
+
def run(
|
|
25
|
+
self,
|
|
26
|
+
text: str,
|
|
27
|
+
*,
|
|
28
|
+
session_id: str | None = None,
|
|
29
|
+
consumer_id: str | None = None,
|
|
30
|
+
attributes: dict[str, Any] | None = None,
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
"""Evaluate nonempty text; optional IDs and attributes provide policy and tracing context."""
|
|
33
|
+
validate_text(text)
|
|
34
|
+
messages = [{"role": "user" if self.direction == "input" else "assistant", "content": text}]
|
|
35
|
+
body = self._body(messages, session_id=session_id, consumer_id=consumer_id, attributes=attributes)
|
|
36
|
+
verdict, transformed = self._client.evaluate(body)
|
|
37
|
+
return self._result(text, messages, verdict, transformed)
|
|
38
|
+
|
|
39
|
+
@component.output_types(text=str, verdict=dict[str, Any])
|
|
40
|
+
async def run_async(
|
|
41
|
+
self,
|
|
42
|
+
text: str,
|
|
43
|
+
*,
|
|
44
|
+
session_id: str | None = None,
|
|
45
|
+
consumer_id: str | None = None,
|
|
46
|
+
attributes: dict[str, Any] | None = None,
|
|
47
|
+
) -> dict[str, Any]:
|
|
48
|
+
"""Evaluate text using native async HTTP; safe to reuse across separate event loops."""
|
|
49
|
+
validate_text(text)
|
|
50
|
+
messages = [{"role": "user" if self.direction == "input" else "assistant", "content": text}]
|
|
51
|
+
body = self._body(messages, session_id=session_id, consumer_id=consumer_id, attributes=attributes)
|
|
52
|
+
verdict, transformed = await self._client.evaluate_async(body)
|
|
53
|
+
return self._result(text, messages, verdict, transformed)
|
|
54
|
+
|
|
55
|
+
def _result(
|
|
56
|
+
self, text: str, messages: list[dict[str, Any]], verdict: dict[str, Any], transformed: Any
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
if self._is_blocked(verdict):
|
|
59
|
+
return {"verdict": verdict}
|
|
60
|
+
if verdict["status"] == "transform":
|
|
61
|
+
text = transformed_texts(transformed, messages)[0]
|
|
62
|
+
return {"text": text, "verdict": verdict}
|
|
File without changes
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: neuraltrust-haystack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: NeuralTrust TrustGuard security components for Haystack pipelines
|
|
5
|
+
Project-URL: Homepage, https://neuraltrust.ai
|
|
6
|
+
Project-URL: Documentation, https://docs.neuraltrust.ai/integrations/haystack
|
|
7
|
+
Project-URL: Changelog, https://github.com/NeuralTrust/neuraltrust-haystack/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Repository, https://github.com/NeuralTrust/neuraltrust-haystack
|
|
9
|
+
Project-URL: Issues, https://github.com/NeuralTrust/neuraltrust-haystack/issues
|
|
10
|
+
Author: NeuralTrust
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: guardrails,haystack,neuraltrust,security,trustguard
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Security
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: haystack-ai<4,>=2.31.0
|
|
27
|
+
Requires-Dist: httpx<1,>=0.27
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# neuraltrust-haystack
|
|
31
|
+
|
|
32
|
+
Add [NeuralTrust TrustGuard](https://neuraltrust.ai) evaluation to Haystack text and chat pipelines. Screen user input before a model runs, or inspect completed assistant replies before returning them to your application.
|
|
33
|
+
|
|
34
|
+
Read the [official Haystack integration guide](https://docs.neuraltrust.ai/integrations/haystack) for setup and usage documentation.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
Requires Python 3.10+ and Haystack 2.31 or 3.x (`haystack-ai>=2.31.0,<4`).
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install neuraltrust-haystack
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For installation from source and development checks, see the [contributing guide](https://github.com/NeuralTrust/neuraltrust-haystack/blob/main/CONTRIBUTING.md).
|
|
45
|
+
|
|
46
|
+
## Connect to TrustGuard
|
|
47
|
+
|
|
48
|
+
Create or select a TrustGuard collector with the policy you want to evaluate, then set its API key in your environment:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
export TRUSTGUARD_API_KEY="your-collector-api-key"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The default API origin is `https://trustguard.neuraltrust.ai`. Pass `api_base` for the public HTTPS origin of a regional or self-hosted deployment. The component sends evaluation requests to `/v1/evaluate`.
|
|
55
|
+
|
|
56
|
+
The API key selects the collector and its policy. The input/output direction selects the policy phase. An `allow` result only reflects the configured policy; a collector without applicable checks does not establish that content was scanned for every threat.
|
|
57
|
+
|
|
58
|
+
## Components
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from haystack_integrations.components.guardrails.neuraltrust import (
|
|
62
|
+
NeuralTrustChatGuard,
|
|
63
|
+
NeuralTrustGuard,
|
|
64
|
+
)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
| Component | Required run input | Passing output |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| `NeuralTrustGuard` | `text: str` | `text: str`, `verdict: dict` |
|
|
70
|
+
| `NeuralTrustChatGuard` | `messages: list[ChatMessage]` | `messages: list[ChatMessage]`, `verdict: dict` |
|
|
71
|
+
|
|
72
|
+
Both components implement `run`, `run_async`, `to_dict`, and `from_dict`.
|
|
73
|
+
|
|
74
|
+
### Screen text
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from haystack_integrations.components.guardrails.neuraltrust import NeuralTrustGuard
|
|
78
|
+
|
|
79
|
+
with NeuralTrustGuard() as guard:
|
|
80
|
+
result = guard.run(text="What is the capital of France?")
|
|
81
|
+
print(result["text"])
|
|
82
|
+
print(result["verdict"]["status"])
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The default `on_violation="raise"` stops execution with `NeuralTrustBlockedError` for a `block` or `ask` verdict. API and response errors also stop execution.
|
|
86
|
+
|
|
87
|
+
### Route a pipeline
|
|
88
|
+
|
|
89
|
+
Use `on_violation="route"` when the application should handle denied requests through the verdict output:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from haystack import Pipeline, component
|
|
93
|
+
|
|
94
|
+
from haystack_integrations.components.guardrails.neuraltrust import NeuralTrustGuard
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@component
|
|
98
|
+
class AcceptText:
|
|
99
|
+
@component.output_types(accepted=str)
|
|
100
|
+
def run(self, text: str) -> dict[str, str]:
|
|
101
|
+
return {"accepted": text}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
with NeuralTrustGuard(on_violation="route") as guard:
|
|
105
|
+
pipeline = Pipeline()
|
|
106
|
+
pipeline.add_component("guard", guard)
|
|
107
|
+
pipeline.add_component("accept", AcceptText())
|
|
108
|
+
pipeline.connect("guard.text", "accept.text")
|
|
109
|
+
|
|
110
|
+
result = pipeline.run(
|
|
111
|
+
{"guard": {"text": "What is the capital of France?"}},
|
|
112
|
+
include_outputs_from={"guard"},
|
|
113
|
+
)
|
|
114
|
+
print(result["guard"]["verdict"]["status"])
|
|
115
|
+
if "accept" in result:
|
|
116
|
+
print(result["accept"]["accepted"])
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
On `block` or `ask`, the guard emits only `verdict`. The required `accept.text` input receives no value, so that component does not run. The guard omits the passing socket entirely: emitting an empty string or empty list would still supply a value to a downstream component. Keep guarded content connected through the guard's output, and use required inputs for the protected downstream step.
|
|
120
|
+
|
|
121
|
+
From a source checkout, the [text example](https://github.com/NeuralTrust/neuraltrust-haystack/blob/main/examples/text_pipeline.py) runs this pattern from the command line:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
uv run python examples/text_pipeline.py "What is the capital of France?"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Screen completed chat replies
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from haystack.dataclasses import ChatMessage
|
|
131
|
+
|
|
132
|
+
from haystack_integrations.components.guardrails.neuraltrust import NeuralTrustChatGuard
|
|
133
|
+
|
|
134
|
+
with NeuralTrustChatGuard(direction="output") as guard:
|
|
135
|
+
result = guard.run(messages=[ChatMessage.from_assistant("Paris is the capital of France.")])
|
|
136
|
+
print(result["messages"][0].text)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Connect `chat_generator.replies` to `guard.messages` to evaluate completed generator replies. In a source checkout, the [chat example](https://github.com/NeuralTrust/neuraltrust-haystack/blob/main/examples/chat_pipeline.py) uses a local component that produces a fixed assistant reply:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
uv run python examples/chat_pipeline.py
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The chat guard accepts a nonempty list of `system`, `user`, and `assistant` messages, each with exactly one nonempty text part, and preserves message names and metadata. Multiple content parts, reasoning, multimodal content, tool calls, and tool results are rejected. Transformed responses must map unambiguously to the original messages; incompatible message counts, roles, or content fail closed. The text guard also rejects empty or whitespace-only input.
|
|
146
|
+
|
|
147
|
+
### Async execution
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
import asyncio
|
|
151
|
+
|
|
152
|
+
from haystack_integrations.components.guardrails.neuraltrust import NeuralTrustGuard
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def main() -> None:
|
|
156
|
+
async with NeuralTrustGuard() as guard:
|
|
157
|
+
result = await guard.run_async(text="What is the capital of France?")
|
|
158
|
+
print(result["verdict"]["status"])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
asyncio.run(main())
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
For async pipelines, Haystack 3.x uses `await Pipeline.run_async(...)`; Haystack 2.31 uses `await AsyncPipeline.run_async(...)` with `AsyncPipeline` imported from `haystack`. Synchronous and asynchronous calls use the same component inputs, verdict handling, and error behavior.
|
|
165
|
+
|
|
166
|
+
### Client lifetime
|
|
167
|
+
|
|
168
|
+
Reuse guard instances across evaluations to reuse HTTP connections. Synchronous calls share a pool; asynchronous calls use a separate pool for each event loop. Async client and TLS setup runs off the event loop and is shared by concurrent initial calls. Credentials still resolve on every evaluation.
|
|
169
|
+
|
|
170
|
+
Use `with guard` for synchronous work or `async with guard` around the lifetime of an asynchronous pipeline. At application shutdown, `guard.close()` drains the synchronous pool. `await guard.aclose()` drains both the synchronous pool and the current loop's asynchronous pool. Call it in each owning event loop before that loop stops. Cleanup is idempotent; subsequent evaluations can create a fresh pool. Network clients and locks are excluded from serialization and component copies.
|
|
171
|
+
|
|
172
|
+
## Configuration
|
|
173
|
+
|
|
174
|
+
All constructor arguments are keyword-only.
|
|
175
|
+
|
|
176
|
+
| Argument | Default | Purpose |
|
|
177
|
+
| --- | --- | --- |
|
|
178
|
+
| `api_key` | `Secret.from_env_var("TRUSTGUARD_API_KEY")` | Haystack Secret containing the evaluation credential. |
|
|
179
|
+
| `api_base` | `https://trustguard.neuraltrust.ai` | Public HTTPS API origin. |
|
|
180
|
+
| `direction` | `"input"` | Policy phase: `"input"` or `"output"`. |
|
|
181
|
+
| `on_violation` | `"raise"` | `"raise"` stops with an exception; `"route"` returns only the verdict for `block`/`ask`. |
|
|
182
|
+
| `timeout` | `5.0` | Positive HTTP timeout in seconds for each network operation, not an overall retry deadline. |
|
|
183
|
+
| `max_retries` | `2` | Additional attempts for eligible transient failures; integer from 0 to 10. |
|
|
184
|
+
| `collector_key` | `None` | Optional collector identifier when using a service token. This is not an API credential. |
|
|
185
|
+
|
|
186
|
+
The optional keyword-only run arguments `session_id`, `consumer_id`, and `attributes` attach request context. `attributes` must contain JSON-compatible values. `consumer_id` can select a policy override configured in TrustGuard.
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
with NeuralTrustGuard() as guard:
|
|
190
|
+
result = guard.run(
|
|
191
|
+
text="What is the capital of France?",
|
|
192
|
+
session_id="example-session",
|
|
193
|
+
consumer_id="example-consumer",
|
|
194
|
+
attributes={"source": {"application": "haystack-example"}},
|
|
195
|
+
)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Configure policies in TrustGuard. These components do not accept a per-request policy ID or detector ID.
|
|
199
|
+
|
|
200
|
+
## Verdicts and errors
|
|
201
|
+
|
|
202
|
+
| TrustGuard status | Component behavior |
|
|
203
|
+
| --- | --- |
|
|
204
|
+
| `allow` | Forward original content. |
|
|
205
|
+
| `report` | Forward original content and return findings in `verdict`. |
|
|
206
|
+
| `transform` | Forward validated transformed content. |
|
|
207
|
+
| `block` | Raise or omit the passing output, according to `on_violation`. |
|
|
208
|
+
| `ask` | Stop like `block`, preserving the `ask` status. This package does not grant approval. |
|
|
209
|
+
|
|
210
|
+
The verdict contains `status` and, when provided, `findings`, `trace_id`, and `request_id`. Findings can contain sensitive evidence from evaluated content. Select the fields your application needs and apply its normal access and retention controls; avoid dumping the full verdict into logs.
|
|
211
|
+
|
|
212
|
+
Import the exceptions from the same public component namespace:
|
|
213
|
+
|
|
214
|
+
| Exception | Meaning |
|
|
215
|
+
| --- | --- |
|
|
216
|
+
| `NeuralTrustBlockedError` | `block` or `ask`; exposes `status` and a verdict limited to status and validated correlation IDs. |
|
|
217
|
+
| `NeuralTrustAuthenticationError` | Missing/invalid credentials or HTTP 401/403. |
|
|
218
|
+
| `NeuralTrustUnavailableError` | Retryable failure exhausted the configured attempts. |
|
|
219
|
+
| `NeuralTrustRequestError` | Rejected request or non-retryable transport failure. |
|
|
220
|
+
| `NeuralTrustInvalidResponseError` | Malformed verdict or unusable transformation. |
|
|
221
|
+
| `NeuralTrustError` | Base class for the errors above. |
|
|
222
|
+
|
|
223
|
+
Exceptions use sanitized messages. A Haystack pipeline may wrap component failures in its own execution exception; inspect the chained cause when handling a specific NeuralTrust error at the pipeline boundary.
|
|
224
|
+
|
|
225
|
+
Retries cover timeouts, connection failures, and HTTP 429/502/504. TLS failures, authentication failures, other HTTP errors, and invalid verdicts are not converted into passing content. Retry delays are bounded and honor supported `Retry-After` values up to five seconds. There is no fail-open mode; `on_violation="route"` changes only the handling of valid `block` and `ask` verdicts.
|
|
226
|
+
|
|
227
|
+
## Save and restore pipelines
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
from haystack import Pipeline
|
|
231
|
+
|
|
232
|
+
from haystack_integrations.components.guardrails.neuraltrust import NeuralTrustGuard
|
|
233
|
+
|
|
234
|
+
pipeline = Pipeline()
|
|
235
|
+
pipeline.add_component("guard", NeuralTrustGuard())
|
|
236
|
+
serialized = pipeline.dumps()
|
|
237
|
+
restored = Pipeline.loads(serialized)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Environment-based Secrets serialize the variable name, never its resolved value. Set the credential in the restoring process before running the pipeline. `Secret.from_token(...)` is supported for direct use, but Haystack intentionally refuses to serialize token-based Secrets. The canonical `haystack_integrations` namespace also works with Haystack 3.x's default deserialization allowlist.
|
|
241
|
+
|
|
242
|
+
## Scope
|
|
243
|
+
|
|
244
|
+
- Text and text-only chat are supported. Document batches, tools, multimodal data, and native Agent lifecycle hooks are outside the components' supported interface.
|
|
245
|
+
- A guard before/after an Agent covers its pipeline input/output. It does not intercept the Agent's internal model calls or tool actions.
|
|
246
|
+
- Output evaluation happens after a completed reply. Tokens already delivered through a streaming callback cannot be withheld by a later pipeline component. Buffer replies when they must pass evaluation before delivery.
|
|
247
|
+
- Detection and transformation depend on the collector policy, its direction, and the TrustGuard service. Local validation does not establish detection accuracy for every policy or input.
|
|
248
|
+
|
|
249
|
+
See the [Haystack integration guide](https://docs.neuraltrust.ai/integrations/haystack) for usage documentation and the [contributing guide](https://github.com/NeuralTrust/neuraltrust-haystack/blob/main/CONTRIBUTING.md) for development checks.
|
|
250
|
+
|
|
251
|
+
Release history is recorded in the [changelog](https://github.com/NeuralTrust/neuraltrust-haystack/blob/main/CHANGELOG.md).
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
This package is distributed under the [MIT License](https://github.com/NeuralTrust/neuraltrust-haystack/blob/main/LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
haystack_integrations/components/guardrails/neuraltrust/__init__.py,sha256=Fn9XCy9XlPM7UJbsfCSCNuhVAYVqmLRlnHvubOX7xuU,677
|
|
2
|
+
haystack_integrations/components/guardrails/neuraltrust/_base.py,sha256=tn6-S3W1jqkrt1xARfFCs7S_R7jVBuoWEXKpmvkKMyo,10314
|
|
3
|
+
haystack_integrations/components/guardrails/neuraltrust/_client.py,sha256=LI53EsDokBn_8i1fepq4GAzT2xNfLyr1luxEg1Nn-vs,15894
|
|
4
|
+
haystack_integrations/components/guardrails/neuraltrust/_version.py,sha256=VDsBswzfU7gNsBkIaxxV6tsi0ixlms3WjN7HYgZJZ2g,46
|
|
5
|
+
haystack_integrations/components/guardrails/neuraltrust/chat_guard.py,sha256=85PuQWAJLqtX7MfPq2dr6OND03JQZriuOHiDd0BnU3w,4424
|
|
6
|
+
haystack_integrations/components/guardrails/neuraltrust/errors.py,sha256=VLwxVn7_Ik_qy6NUSR31GJMncCsrH3mDnPEx45_ayVk,1548
|
|
7
|
+
haystack_integrations/components/guardrails/neuraltrust/guard.py,sha256=5Ufk0_6MiL93EB6798oHiP8sdmZ85wbRRdEI6Yy4mgI,2667
|
|
8
|
+
haystack_integrations/components/guardrails/neuraltrust/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
neuraltrust_haystack-0.1.0.dist-info/METADATA,sha256=Hjq-obBepQ3x88GUsnXn0K4Ao6ZLcWiL4rG851UCcio,13058
|
|
10
|
+
neuraltrust_haystack-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
11
|
+
neuraltrust_haystack-0.1.0.dist-info/licenses/LICENSE,sha256=QAfBoTjdYqFWAoNpzfHjWN2bc3Py1uXPq2tqnjKp150,1068
|
|
12
|
+
neuraltrust_haystack-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NeuralTrust
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|