valguard-sdk 0.3.1__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.
- valguard/__init__.py +52 -0
- valguard/client.py +298 -0
- valguard/constants.py +4 -0
- valguard/errors.py +55 -0
- valguard/evidence.py +49 -0
- valguard/headers.py +119 -0
- valguard/webhook.py +56 -0
- valguard_sdk-0.3.1.dist-info/METADATA +132 -0
- valguard_sdk-0.3.1.dist-info/RECORD +12 -0
- valguard_sdk-0.3.1.dist-info/WHEEL +5 -0
- valguard_sdk-0.3.1.dist-info/licenses/LICENSE +21 -0
- valguard_sdk-0.3.1.dist-info/top_level.txt +1 -0
valguard/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""ValGuard integration helpers for the OpenAI-compatible layer."""
|
|
2
|
+
|
|
3
|
+
from valguard.client import ChatCompletionResult, TestValidatorsResult, ValGuardClient
|
|
4
|
+
from valguard.errors import ValGuardError, ValGuardErrorInfo, parse_valguard_error
|
|
5
|
+
from valguard.evidence import EVIDENCE_TYP, verify_evidence, verify_proof
|
|
6
|
+
from valguard.headers import ValidationHeaders, parse_validation_headers
|
|
7
|
+
from valguard.webhook import (
|
|
8
|
+
WEBHOOK_SIGNATURE_HEADER,
|
|
9
|
+
sign_webhook_payload,
|
|
10
|
+
verify_webhook_signature,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def openai_client_kwargs(
|
|
15
|
+
api_key: str,
|
|
16
|
+
base_url: str,
|
|
17
|
+
agent_slug: str,
|
|
18
|
+
provider_key: str | None = None,
|
|
19
|
+
) -> dict:
|
|
20
|
+
"""Backward-compatible helper — prefer ValGuardClient.openai_client_kwargs().
|
|
21
|
+
|
|
22
|
+
Pass provider_key so the OpenAI client sends X-Provider-Key (BYOK).
|
|
23
|
+
"""
|
|
24
|
+
return ValGuardClient(
|
|
25
|
+
api_key=api_key,
|
|
26
|
+
base_url=base_url,
|
|
27
|
+
agent_slug=agent_slug,
|
|
28
|
+
provider_key=provider_key,
|
|
29
|
+
).openai_client_kwargs()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"ChatCompletionResult",
|
|
34
|
+
"EVIDENCE_TYP",
|
|
35
|
+
"TestValidatorsResult",
|
|
36
|
+
"ValGuardClient",
|
|
37
|
+
"ValGuardError",
|
|
38
|
+
"ValGuardErrorInfo",
|
|
39
|
+
"ValidationHeaders",
|
|
40
|
+
"WEBHOOK_SIGNATURE_HEADER",
|
|
41
|
+
"openai_client_kwargs",
|
|
42
|
+
"parse_response_headers",
|
|
43
|
+
"parse_valguard_error",
|
|
44
|
+
"parse_validation_headers",
|
|
45
|
+
"sign_webhook_payload",
|
|
46
|
+
"verify_evidence",
|
|
47
|
+
"verify_proof",
|
|
48
|
+
"verify_webhook_signature",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# Alias for symmetry with the Node SDK
|
|
52
|
+
parse_response_headers = parse_validation_headers
|
valguard/client.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""ValGuard client — OpenAI-compatible layer helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from dataclasses import dataclass, field, replace
|
|
10
|
+
from typing import Any, Dict, Mapping, Sequence, Union
|
|
11
|
+
|
|
12
|
+
from .errors import ValGuardError, ValGuardErrorInfo, parse_valguard_error
|
|
13
|
+
from .headers import ValidationHeaders, parse_validation_headers
|
|
14
|
+
|
|
15
|
+
RAGSource = Union[str, Dict[str, str]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ChatCompletionResult:
|
|
20
|
+
status: int
|
|
21
|
+
data: Any
|
|
22
|
+
validation: ValidationHeaders
|
|
23
|
+
error: ValGuardErrorInfo | None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class TestValidatorsResult:
|
|
28
|
+
status: int
|
|
29
|
+
data: Any
|
|
30
|
+
validation: ValidationHeaders
|
|
31
|
+
error: ValGuardErrorInfo | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ValGuardClient:
|
|
36
|
+
api_key: str
|
|
37
|
+
base_url: str
|
|
38
|
+
agent_slug: str = "default"
|
|
39
|
+
flow_slug: str | None = None
|
|
40
|
+
provider_key: str | None = None
|
|
41
|
+
request_id: str | None = None
|
|
42
|
+
rag_sources: Sequence[RAGSource] = field(default_factory=list)
|
|
43
|
+
internal_secret: str | None = None
|
|
44
|
+
project_id: str | None = None
|
|
45
|
+
idempotency_key: str | None = None
|
|
46
|
+
approval_mode: str | None = None
|
|
47
|
+
traceparent: str | None = None
|
|
48
|
+
parent_span_id: str | None = None
|
|
49
|
+
agent_id: str | None = None
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_env(cls, environ: Mapping[str, str] | None = None) -> ValGuardClient:
|
|
53
|
+
env = dict(os.environ if environ is None else environ)
|
|
54
|
+
api_key = env.get("VG_API_KEY") or env.get("VALGUARD_API_KEY")
|
|
55
|
+
base_url = env.get("VG_PROXY") or env.get("VALGUARD_BASE_URL")
|
|
56
|
+
agent_slug = env.get("VG_AGENT") or env.get("VALGUARD_AGENT") or "default"
|
|
57
|
+
provider_key = env.get("VG_PROVIDER_KEY") or env.get("OPENAI_API_KEY")
|
|
58
|
+
internal_secret = env.get("INTERNAL_PROXY_SECRET") or env.get("VG_INTERNAL_SECRET")
|
|
59
|
+
project_id = env.get("VG_PROJECT_ID") or env.get("VG_TEST_PROJECT_ID")
|
|
60
|
+
|
|
61
|
+
if not api_key and not (internal_secret and project_id):
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"Missing VG_API_KEY (or INTERNAL_PROXY_SECRET + VG_PROJECT_ID for internal test endpoints)"
|
|
64
|
+
)
|
|
65
|
+
if not base_url:
|
|
66
|
+
raise ValueError("Missing VG_PROXY (layer base URL)")
|
|
67
|
+
|
|
68
|
+
return cls(
|
|
69
|
+
api_key=api_key or "",
|
|
70
|
+
base_url=base_url,
|
|
71
|
+
agent_slug=agent_slug,
|
|
72
|
+
provider_key=provider_key,
|
|
73
|
+
internal_secret=internal_secret,
|
|
74
|
+
project_id=project_id,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def chat_completions_url(self) -> str:
|
|
79
|
+
return f"{self.base_url.rstrip('/')}/v1/chat/completions"
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def test_validators_url(self) -> str:
|
|
83
|
+
return f"{self.base_url.rstrip('/')}/internal/test-validators"
|
|
84
|
+
|
|
85
|
+
def _build_internal_test_headers(self, extra: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
86
|
+
headers: dict[str, str] = {}
|
|
87
|
+
if self.api_key:
|
|
88
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
89
|
+
if self.internal_secret:
|
|
90
|
+
headers["X-VG-Internal-Secret"] = self.internal_secret
|
|
91
|
+
if self.project_id:
|
|
92
|
+
headers["X-VG-Project-Id"] = self.project_id
|
|
93
|
+
if not headers.get("Authorization") and not (self.internal_secret and self.project_id):
|
|
94
|
+
raise ValueError(
|
|
95
|
+
"test_validators requires api_key or both internal_secret and project_id"
|
|
96
|
+
)
|
|
97
|
+
if extra:
|
|
98
|
+
headers.update(extra)
|
|
99
|
+
return headers
|
|
100
|
+
|
|
101
|
+
def test_validators(
|
|
102
|
+
self,
|
|
103
|
+
*,
|
|
104
|
+
output: str,
|
|
105
|
+
agent_slug: str,
|
|
106
|
+
input_text: str = "",
|
|
107
|
+
rag_sources: Sequence[RAGSource] | None = None,
|
|
108
|
+
reask_attempt: int | None = None,
|
|
109
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
110
|
+
timeout: float = 60.0,
|
|
111
|
+
) -> TestValidatorsResult:
|
|
112
|
+
if not output or not agent_slug:
|
|
113
|
+
raise ValueError("test_validators requires output and agent_slug")
|
|
114
|
+
|
|
115
|
+
body: dict[str, Any] = {
|
|
116
|
+
"output": output,
|
|
117
|
+
"agent_slug": agent_slug,
|
|
118
|
+
}
|
|
119
|
+
if input_text:
|
|
120
|
+
body["input_text"] = input_text
|
|
121
|
+
if rag_sources:
|
|
122
|
+
body["rag_sources"] = list(rag_sources)
|
|
123
|
+
if reask_attempt is not None:
|
|
124
|
+
body["reask_attempt"] = reask_attempt
|
|
125
|
+
|
|
126
|
+
payload = json.dumps(body).encode("utf-8")
|
|
127
|
+
headers = self._build_internal_test_headers(
|
|
128
|
+
{"Content-Type": "application/json", **dict(extra_headers or {})}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
request = urllib.request.Request(
|
|
132
|
+
self.test_validators_url,
|
|
133
|
+
data=payload,
|
|
134
|
+
headers=headers,
|
|
135
|
+
method="POST",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
140
|
+
status = response.status
|
|
141
|
+
raw = response.read().decode("utf-8")
|
|
142
|
+
response_headers = {k: v for k, v in response.headers.items()}
|
|
143
|
+
except urllib.error.HTTPError as exc:
|
|
144
|
+
status = exc.code
|
|
145
|
+
raw = exc.read().decode("utf-8")
|
|
146
|
+
response_headers = {k: v for k, v in exc.headers.items()}
|
|
147
|
+
|
|
148
|
+
data: Any
|
|
149
|
+
if raw:
|
|
150
|
+
try:
|
|
151
|
+
data = json.loads(raw)
|
|
152
|
+
except json.JSONDecodeError:
|
|
153
|
+
data = raw
|
|
154
|
+
else:
|
|
155
|
+
data = None
|
|
156
|
+
|
|
157
|
+
validation = parse_validation_headers(response_headers)
|
|
158
|
+
error = parse_valguard_error(status, data)
|
|
159
|
+
|
|
160
|
+
if error and status >= 400:
|
|
161
|
+
raise ValGuardError(status, error)
|
|
162
|
+
|
|
163
|
+
return TestValidatorsResult(status=status, data=data, validation=validation, error=error)
|
|
164
|
+
|
|
165
|
+
def openai_client_kwargs(self) -> dict[str, Any]:
|
|
166
|
+
default_headers = self.build_headers()
|
|
167
|
+
default_headers.pop("Authorization", None)
|
|
168
|
+
return {
|
|
169
|
+
"api_key": self.api_key,
|
|
170
|
+
"base_url": f"{self.base_url.rstrip('/')}/v1",
|
|
171
|
+
"default_headers": default_headers,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
def _assert_mutually_exclusive(self) -> None:
|
|
175
|
+
if self.flow_slug and self.agent_slug:
|
|
176
|
+
raise ValueError("mutually_exclusive_headers: use either with_agent or with_playbook, not both")
|
|
177
|
+
|
|
178
|
+
def build_headers(self, extra: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
179
|
+
self._assert_mutually_exclusive()
|
|
180
|
+
headers: dict[str, str] = {
|
|
181
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
182
|
+
}
|
|
183
|
+
if self.flow_slug:
|
|
184
|
+
headers["X-VG-Flow"] = self.flow_slug
|
|
185
|
+
else:
|
|
186
|
+
headers["X-VG-Agent"] = self.agent_slug
|
|
187
|
+
if self.provider_key:
|
|
188
|
+
headers["X-Provider-Key"] = self.provider_key
|
|
189
|
+
if self.request_id:
|
|
190
|
+
headers["X-Request-Id"] = self.request_id
|
|
191
|
+
if self.rag_sources:
|
|
192
|
+
headers["X-VG-Sources"] = json.dumps(list(self.rag_sources))
|
|
193
|
+
if self.idempotency_key:
|
|
194
|
+
headers["X-VG-Idempotency-Key"] = self.idempotency_key
|
|
195
|
+
if self.approval_mode:
|
|
196
|
+
headers["X-VG-Approval-Mode"] = self.approval_mode
|
|
197
|
+
if self.traceparent:
|
|
198
|
+
headers["traceparent"] = self.traceparent
|
|
199
|
+
if self.parent_span_id:
|
|
200
|
+
headers["X-VG-Parent-Span-Id"] = self.parent_span_id
|
|
201
|
+
if self.agent_id:
|
|
202
|
+
headers["X-VG-Agent-Id"] = self.agent_id
|
|
203
|
+
if extra:
|
|
204
|
+
headers.update(extra)
|
|
205
|
+
return headers
|
|
206
|
+
|
|
207
|
+
def with_agent(self, agent_slug: str) -> ValGuardClient:
|
|
208
|
+
return replace(self, agent_slug=agent_slug, flow_slug=None)
|
|
209
|
+
|
|
210
|
+
def with_playbook(self, flow_slug: str) -> ValGuardClient:
|
|
211
|
+
return replace(self, flow_slug=flow_slug, agent_slug="")
|
|
212
|
+
|
|
213
|
+
def with_provider_key(self, provider_key: str) -> ValGuardClient:
|
|
214
|
+
return replace(self, provider_key=provider_key)
|
|
215
|
+
|
|
216
|
+
def with_rag_sources(self, sources: Sequence[RAGSource]) -> ValGuardClient:
|
|
217
|
+
return replace(self, rag_sources=list(sources))
|
|
218
|
+
|
|
219
|
+
def with_idempotency_key(self, key: str) -> ValGuardClient:
|
|
220
|
+
return replace(self, idempotency_key=key)
|
|
221
|
+
|
|
222
|
+
def with_approval_mode(self, mode: str) -> ValGuardClient:
|
|
223
|
+
return replace(self, approval_mode=mode)
|
|
224
|
+
|
|
225
|
+
def with_trace(
|
|
226
|
+
self,
|
|
227
|
+
*,
|
|
228
|
+
traceparent: str | None = None,
|
|
229
|
+
parent_span_id: str | None = None,
|
|
230
|
+
agent_id: str | None = None,
|
|
231
|
+
) -> ValGuardClient:
|
|
232
|
+
return replace(
|
|
233
|
+
self,
|
|
234
|
+
traceparent=traceparent,
|
|
235
|
+
parent_span_id=parent_span_id,
|
|
236
|
+
agent_id=agent_id,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def parse_response_headers(self, headers: Mapping[str, str]) -> ValidationHeaders:
|
|
240
|
+
return parse_validation_headers(headers)
|
|
241
|
+
|
|
242
|
+
def chat_completions(
|
|
243
|
+
self,
|
|
244
|
+
*,
|
|
245
|
+
model: str,
|
|
246
|
+
messages: Sequence[Mapping[str, str]],
|
|
247
|
+
stream: bool = False,
|
|
248
|
+
temperature: float | None = None,
|
|
249
|
+
max_tokens: int | None = None,
|
|
250
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
251
|
+
timeout: float = 60.0,
|
|
252
|
+
) -> ChatCompletionResult:
|
|
253
|
+
body: dict[str, Any] = {
|
|
254
|
+
"model": model,
|
|
255
|
+
"messages": list(messages),
|
|
256
|
+
"stream": stream,
|
|
257
|
+
}
|
|
258
|
+
if temperature is not None:
|
|
259
|
+
body["temperature"] = temperature
|
|
260
|
+
if max_tokens is not None:
|
|
261
|
+
body["max_tokens"] = max_tokens
|
|
262
|
+
|
|
263
|
+
payload = json.dumps(body).encode("utf-8")
|
|
264
|
+
headers = self.build_headers({"Content-Type": "application/json", **dict(extra_headers or {})})
|
|
265
|
+
|
|
266
|
+
request = urllib.request.Request(
|
|
267
|
+
self.chat_completions_url,
|
|
268
|
+
data=payload,
|
|
269
|
+
headers=headers,
|
|
270
|
+
method="POST",
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
275
|
+
status = response.status
|
|
276
|
+
raw = response.read().decode("utf-8")
|
|
277
|
+
response_headers = {k: v for k, v in response.headers.items()}
|
|
278
|
+
except urllib.error.HTTPError as exc:
|
|
279
|
+
status = exc.code
|
|
280
|
+
raw = exc.read().decode("utf-8")
|
|
281
|
+
response_headers = {k: v for k, v in exc.headers.items()}
|
|
282
|
+
|
|
283
|
+
data: Any
|
|
284
|
+
if raw:
|
|
285
|
+
try:
|
|
286
|
+
data = json.loads(raw)
|
|
287
|
+
except json.JSONDecodeError:
|
|
288
|
+
data = raw
|
|
289
|
+
else:
|
|
290
|
+
data = None
|
|
291
|
+
|
|
292
|
+
validation = parse_validation_headers(response_headers)
|
|
293
|
+
error = parse_valguard_error(status, data)
|
|
294
|
+
|
|
295
|
+
if error and not error.soft_fail and status >= 400:
|
|
296
|
+
raise ValGuardError(status, error)
|
|
297
|
+
|
|
298
|
+
return ChatCompletionResult(status=status, data=data, validation=validation, error=error)
|
valguard/constants.py
ADDED
valguard/errors.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Parse ValGuard layer errors and soft-fail envelopes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ValGuardErrorInfo:
|
|
11
|
+
type: str
|
|
12
|
+
code: str | None
|
|
13
|
+
message: str | None
|
|
14
|
+
reason: str | None
|
|
15
|
+
details: Any
|
|
16
|
+
soft_fail: bool
|
|
17
|
+
body: Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ValGuardError(Exception):
|
|
21
|
+
def __init__(self, status: int, info: ValGuardErrorInfo) -> None:
|
|
22
|
+
super().__init__(info.message or info.reason or f"ValGuard request failed ({status})")
|
|
23
|
+
self.status = status
|
|
24
|
+
self.info = info
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_valguard_error(status: int, body: Any) -> ValGuardErrorInfo | None:
|
|
28
|
+
if not isinstance(body, dict):
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
if body.get("valid") is False and body.get("validation") is not None:
|
|
32
|
+
return ValGuardErrorInfo(
|
|
33
|
+
type="validation_soft_fail",
|
|
34
|
+
code=None,
|
|
35
|
+
message="response failed validation (http 200 envelope)",
|
|
36
|
+
reason=None,
|
|
37
|
+
details=body.get("validation"),
|
|
38
|
+
soft_fail=True,
|
|
39
|
+
body=body,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
error = body.get("error")
|
|
43
|
+
if not isinstance(error, dict):
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
err_type = error.get("type")
|
|
47
|
+
return ValGuardErrorInfo(
|
|
48
|
+
type=str(err_type) if err_type is not None else "unknown",
|
|
49
|
+
code=error.get("code") if isinstance(error.get("code"), str) else None,
|
|
50
|
+
message=error.get("message") if isinstance(error.get("message"), str) else None,
|
|
51
|
+
reason=error.get("reason") if isinstance(error.get("reason"), str) else None,
|
|
52
|
+
details=error.get("details"),
|
|
53
|
+
soft_fail=False,
|
|
54
|
+
body=body,
|
|
55
|
+
)
|
valguard/evidence.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Offline HMAC verification for ValGuard evidence envelopes.
|
|
2
|
+
|
|
3
|
+
Matches `valguard-cli verify-proof` and the proxy signer: org key is
|
|
4
|
+
HMAC-SHA256(secret, key_id), signature is `sha256=` + HMAC-SHA256(org key, payload).
|
|
5
|
+
The payload string is verified as stored. Do not re-serialize the envelope.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any, Mapping
|
|
14
|
+
|
|
15
|
+
EVIDENCE_TYP = "valguard-evidence/1"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def verify_evidence(key_id: str, payload: str, signature: str, secret: str) -> bool:
|
|
19
|
+
"""Return true when `signature` matches HMAC over the exact payload bytes."""
|
|
20
|
+
if not key_id or not payload or not signature or not secret:
|
|
21
|
+
return False
|
|
22
|
+
org_key = hmac.new(secret.encode(), key_id.encode(), hashlib.sha256).digest()
|
|
23
|
+
expected = "sha256=" + hmac.new(org_key, payload.encode(), hashlib.sha256).hexdigest()
|
|
24
|
+
return hmac.compare_digest(expected, signature.strip())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def verify_proof(envelope: str | Mapping[str, Any], secret: str) -> bool:
|
|
28
|
+
"""Verify a downloaded envelope object or its JSON text.
|
|
29
|
+
|
|
30
|
+
Requires `key_id`, `payload`, and `signature`. The caller supplies the
|
|
31
|
+
deployment secret (`EVIDENCE_HMAC_SECRET`, or the webhook secret when that
|
|
32
|
+
is the fallback). This does not call the API.
|
|
33
|
+
"""
|
|
34
|
+
record: Any
|
|
35
|
+
if isinstance(envelope, str):
|
|
36
|
+
try:
|
|
37
|
+
record = json.loads(envelope)
|
|
38
|
+
except json.JSONDecodeError:
|
|
39
|
+
return False
|
|
40
|
+
else:
|
|
41
|
+
record = envelope
|
|
42
|
+
if not isinstance(record, Mapping):
|
|
43
|
+
return False
|
|
44
|
+
return verify_evidence(
|
|
45
|
+
str(record.get("key_id") or ""),
|
|
46
|
+
str(record.get("payload") or ""),
|
|
47
|
+
str(record.get("signature") or ""),
|
|
48
|
+
secret,
|
|
49
|
+
)
|
valguard/headers.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Parse ValGuard observability headers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal, Mapping
|
|
7
|
+
|
|
8
|
+
ValidationStatus = Literal["pass", "warn", "block", "shadow", "stream_passthrough", "fail"]
|
|
9
|
+
|
|
10
|
+
_VALIDATION_STATUSES = {"pass", "warn", "block", "shadow", "stream_passthrough", "fail"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ValidationHeaders:
|
|
15
|
+
validation_status: ValidationStatus | None
|
|
16
|
+
shadow_mode: bool
|
|
17
|
+
latency_ms: int | None
|
|
18
|
+
request_id: str | None
|
|
19
|
+
input_validation_shadow: bool
|
|
20
|
+
validation_phase: Literal["input"] | None
|
|
21
|
+
overage: bool
|
|
22
|
+
target_type: str | None
|
|
23
|
+
orchestration_path: str | None
|
|
24
|
+
orchestration_validations_used: int | None
|
|
25
|
+
orchestration_executions_remaining: int | Literal["unlimited"] | None
|
|
26
|
+
idempotency_replayed: bool
|
|
27
|
+
healed: bool
|
|
28
|
+
healed_rules: tuple[str, ...]
|
|
29
|
+
healed_event_count: int | None
|
|
30
|
+
healed_trace: tuple[str, ...]
|
|
31
|
+
cache_status: str | None
|
|
32
|
+
trace_id: str | None
|
|
33
|
+
span_id: str | None
|
|
34
|
+
parent_span_id: str | None
|
|
35
|
+
agent_id: str | None
|
|
36
|
+
idempotent_run_id: str | None
|
|
37
|
+
run_id: str | None
|
|
38
|
+
run_status: str | None
|
|
39
|
+
approval_id: str | None
|
|
40
|
+
license_status: str | None
|
|
41
|
+
license_mode: str | None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _header_get(headers: Mapping[str, str], name: str) -> str | None:
|
|
45
|
+
lower = name.lower()
|
|
46
|
+
for key, value in headers.items():
|
|
47
|
+
if key.lower() == lower:
|
|
48
|
+
if value is None or value == "":
|
|
49
|
+
return None
|
|
50
|
+
return value
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _split_csv(raw: str | None) -> tuple[str, ...]:
|
|
55
|
+
if not raw:
|
|
56
|
+
return ()
|
|
57
|
+
return tuple(part.strip() for part in raw.split(",") if part.strip())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_int(raw: str | None) -> int | None:
|
|
61
|
+
if raw is None:
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
return int(raw)
|
|
65
|
+
except ValueError:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_validation_headers(headers: Mapping[str, str]) -> ValidationHeaders:
|
|
70
|
+
status = _header_get(headers, "x-vg-validation-status")
|
|
71
|
+
if status is not None:
|
|
72
|
+
status = status.lower()
|
|
73
|
+
validation_status: ValidationStatus | None
|
|
74
|
+
if status in _VALIDATION_STATUSES:
|
|
75
|
+
validation_status = status # type: ignore[assignment]
|
|
76
|
+
else:
|
|
77
|
+
validation_status = None
|
|
78
|
+
|
|
79
|
+
phase = _header_get(headers, "x-vg-validation-phase")
|
|
80
|
+
validation_phase: Literal["input"] | None = "input" if phase == "input" else None
|
|
81
|
+
|
|
82
|
+
remaining_raw = _header_get(headers, "x-vg-requests-remaining")
|
|
83
|
+
orchestration_executions_remaining: int | Literal["unlimited"] | None
|
|
84
|
+
if remaining_raw is None:
|
|
85
|
+
orchestration_executions_remaining = None
|
|
86
|
+
elif remaining_raw == "unlimited":
|
|
87
|
+
orchestration_executions_remaining = "unlimited"
|
|
88
|
+
else:
|
|
89
|
+
orchestration_executions_remaining = _parse_int(remaining_raw)
|
|
90
|
+
|
|
91
|
+
return ValidationHeaders(
|
|
92
|
+
validation_status=validation_status,
|
|
93
|
+
shadow_mode=_header_get(headers, "x-vg-shadow-mode") == "true",
|
|
94
|
+
latency_ms=_parse_int(_header_get(headers, "x-vg-latency-ms")),
|
|
95
|
+
request_id=_header_get(headers, "x-vg-request-id"),
|
|
96
|
+
input_validation_shadow=_header_get(headers, "x-vg-input-validation") == "shadow",
|
|
97
|
+
validation_phase=validation_phase,
|
|
98
|
+
overage=_header_get(headers, "x-vg-overage") == "true",
|
|
99
|
+
target_type=_header_get(headers, "x-vg-target-type"),
|
|
100
|
+
orchestration_path=_header_get(headers, "x-vg-orchestration-path"),
|
|
101
|
+
orchestration_validations_used=_parse_int(_header_get(headers, "x-vg-orchestration-validations-used")),
|
|
102
|
+
orchestration_executions_remaining=orchestration_executions_remaining,
|
|
103
|
+
idempotency_replayed=_header_get(headers, "x-vg-idempotency-replayed") == "true",
|
|
104
|
+
healed=_header_get(headers, "x-vg-healed") == "true",
|
|
105
|
+
healed_rules=_split_csv(_header_get(headers, "x-vg-healed-rules")),
|
|
106
|
+
healed_event_count=_parse_int(_header_get(headers, "x-vg-healed-event-count")),
|
|
107
|
+
healed_trace=_split_csv(_header_get(headers, "x-vg-healed-trace")),
|
|
108
|
+
cache_status=_header_get(headers, "x-vg-cache"),
|
|
109
|
+
trace_id=_header_get(headers, "x-trace-id"),
|
|
110
|
+
span_id=_header_get(headers, "x-vg-span-id"),
|
|
111
|
+
parent_span_id=_header_get(headers, "x-vg-parent-span-id"),
|
|
112
|
+
agent_id=_header_get(headers, "x-vg-agent-id"),
|
|
113
|
+
idempotent_run_id=_header_get(headers, "x-vg-idempotent-run-id"),
|
|
114
|
+
run_id=_header_get(headers, "x-vg-run-id"),
|
|
115
|
+
run_status=_header_get(headers, "x-vg-run-status"),
|
|
116
|
+
approval_id=_header_get(headers, "x-vg-approval-id"),
|
|
117
|
+
license_status=_header_get(headers, "x-vg-license-status"),
|
|
118
|
+
license_mode=_header_get(headers, "x-vg-license-mode"),
|
|
119
|
+
)
|
valguard/webhook.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Verify inbound ValGuard webhook HMAC signatures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
WEBHOOK_SIGNATURE_HEADER = "X-VG-Signature"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _as_bytes(raw_body: bytes | str) -> bytes:
|
|
13
|
+
if isinstance(raw_body, str):
|
|
14
|
+
return raw_body.encode("utf-8")
|
|
15
|
+
if isinstance(raw_body, (bytes, bytearray)):
|
|
16
|
+
return bytes(raw_body)
|
|
17
|
+
raise TypeError("raw_body must be str or bytes")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sign_webhook_payload(raw_body: bytes | str, secret: str, unix: int | None = None) -> str:
|
|
21
|
+
"""Return `t=<unix>,v1=<hex>` HMAC-SHA256 over `<unix>.<raw body>`."""
|
|
22
|
+
body = _as_bytes(raw_body)
|
|
23
|
+
ts = int(time.time()) if unix is None else int(unix)
|
|
24
|
+
mac = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256)
|
|
25
|
+
return f"t={ts},v1={mac.hexdigest()}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def verify_webhook_signature(
|
|
29
|
+
raw_body: bytes | str,
|
|
30
|
+
signature_header: str | None,
|
|
31
|
+
secret: str,
|
|
32
|
+
max_age_seconds: int | None = 300,
|
|
33
|
+
) -> bool:
|
|
34
|
+
"""Verify `X-VG-Signature`. Accepts str or bytes bodies and legacy `sha256=` signatures."""
|
|
35
|
+
if not signature_header or not secret:
|
|
36
|
+
return False
|
|
37
|
+
try:
|
|
38
|
+
body = _as_bytes(raw_body)
|
|
39
|
+
except TypeError:
|
|
40
|
+
return False
|
|
41
|
+
header = signature_header.strip()
|
|
42
|
+
if header.startswith("t=") and ",v1=" in header:
|
|
43
|
+
try:
|
|
44
|
+
stamp, hex_sig = header.split(",v1=", 1)
|
|
45
|
+
unix = int(stamp[2:])
|
|
46
|
+
except ValueError:
|
|
47
|
+
return False
|
|
48
|
+
if max_age_seconds is not None and abs(int(time.time()) - unix) > max_age_seconds:
|
|
49
|
+
return False
|
|
50
|
+
expected = sign_webhook_payload(body, secret, unix)
|
|
51
|
+
return hmac.compare_digest(expected, f"t={unix},v1={hex_sig}")
|
|
52
|
+
if header.startswith("sha256="):
|
|
53
|
+
mac = hmac.new(secret.encode(), body, hashlib.sha256)
|
|
54
|
+
expected = f"sha256={mac.hexdigest()}"
|
|
55
|
+
return hmac.compare_digest(expected, header)
|
|
56
|
+
return False
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: valguard-sdk
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: OpenAI-compatible client helpers for the ValGuard integrity layer
|
|
5
|
+
Author: ValGuard
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://valguard.ai
|
|
8
|
+
Project-URL: Documentation, https://valguard.ai/docs/quickstart
|
|
9
|
+
Project-URL: Repository, https://github.com/valguard-ai/sdk-python
|
|
10
|
+
Project-URL: Source, https://github.com/valguard-ai/sdk-python
|
|
11
|
+
Keywords: llm,openai,validation,guardrails,valguard
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Provides-Extra: openai
|
|
24
|
+
Requires-Dist: openai>=1.0.0; extra == "openai"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# valguard-sdk (Python)
|
|
28
|
+
|
|
29
|
+
Thin Python helpers for the ValGuard OpenAI-compatible integrity layer.
|
|
30
|
+
|
|
31
|
+
**Package name: `valguard-sdk`.** `pip install valguard` installs an unrelated third-party project. Import this SDK as `valguard`.
|
|
32
|
+
|
|
33
|
+
Public GitHub: [github.com/valguard-ai/sdk-python](https://github.com/valguard-ai/sdk-python).
|
|
34
|
+
|
|
35
|
+
Set `VG_PROXY` to your layer URL — see [valguard.ai/docs/quickstart](https://valguard.ai/docs/quickstart).
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install valguard-sdk
|
|
41
|
+
# or, from this repo: pip install "git+https://github.com/valguard-ai/sdk-python.git"
|
|
42
|
+
pip install openai # optional, for OpenAI SDK bridge
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Environment
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
export VG_PROXY="https://api.valguard.ai" # your layer base URL
|
|
49
|
+
export VG_API_KEY="vg_live_..."
|
|
50
|
+
export VG_AGENT="invoice_extraction_agent"
|
|
51
|
+
export VG_PROVIDER_KEY="sk-..." # optional BYOK upstream key
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## with_agent and with_playbook
|
|
55
|
+
|
|
56
|
+
`with_agent` sets `X-VG-Agent`. `with_playbook` sets `X-VG-Flow`. Pass `provider_key` (or `VG_PROVIDER_KEY` via `from_env`) so `openai_client_kwargs()` includes `X-Provider-Key`.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
vg = ValGuardClient.from_env().with_playbook("invoice-processing")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## OpenAI SDK (recommended)
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from openai import OpenAI
|
|
66
|
+
from valguard import ValGuardClient
|
|
67
|
+
|
|
68
|
+
vg = ValGuardClient.from_env()
|
|
69
|
+
client = OpenAI(**vg.openai_client_kwargs())
|
|
70
|
+
|
|
71
|
+
raw = client.with_raw_response.chat.completions.create(
|
|
72
|
+
model="openai/gpt-4o-mini",
|
|
73
|
+
messages=[{"role": "user", "content": "Extract invoice JSON"}],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
print(vg.parse_response_headers(dict(raw.http_response.headers)))
|
|
77
|
+
print(raw.parse())
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Stdlib-only HTTP
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from valguard import ValGuardClient
|
|
84
|
+
|
|
85
|
+
vg = ValGuardClient(
|
|
86
|
+
api_key="vg_live_...",
|
|
87
|
+
base_url="https://api.valguard.ai",
|
|
88
|
+
agent_slug="default",
|
|
89
|
+
provider_key="sk-...",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
result = vg.chat_completions(
|
|
93
|
+
model="openai/gpt-4o-mini",
|
|
94
|
+
messages=[{"role": "user", "content": "hi"}],
|
|
95
|
+
)
|
|
96
|
+
print(result.validation)
|
|
97
|
+
print(result.data)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## RAG sources
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
vg = ValGuardClient.from_env().with_rag_sources(
|
|
104
|
+
[{"id": "doc-1", "text": "Acme revenue was $4.2M in Q3."}]
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Errors
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from valguard import ValGuardClient, ValGuardError
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
vg.chat_completions(model="openai/gpt-4o-mini", messages=[])
|
|
115
|
+
except ValGuardError as err:
|
|
116
|
+
print(err.info.type) # validation_block, input_validation, rate_limit_exceeded, ...
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Soft-fail agents return HTTP 200 with `valid: false` — inspect `result.error.soft_fail` instead of exceptions.
|
|
120
|
+
|
|
121
|
+
## API surface
|
|
122
|
+
|
|
123
|
+
| Symbol | Purpose |
|
|
124
|
+
|--------|---------|
|
|
125
|
+
| `ValGuardClient` | Main client + `from_env()` |
|
|
126
|
+
| `openai_client_kwargs()` | Legacy functional helper |
|
|
127
|
+
| `parse_validation_headers()` | Read `X-VG-*` headers, including repair, cache, trace, and approval |
|
|
128
|
+
| `verify_proof()` / `verify_evidence()` | Offline HMAC check of a Proof download (`valguard-evidence/1`) |
|
|
129
|
+
| `parse_valguard_error()` | Parse error / soft-fail JSON |
|
|
130
|
+
| `ValGuardError` | Raised on non-2xx responses |
|
|
131
|
+
|
|
132
|
+
`with_approval_mode("async")` sends `X-VG-Approval-Mode`. `with_idempotency_key` sends `X-VG-Idempotency-Key`. `with_trace` sends `traceparent`, `X-VG-Parent-Span-Id`, and `X-VG-Agent-Id`. Response repair is reported with `X-VG-Healed`, `X-VG-Healed-Rules`, `X-VG-Healed-Event-Count`, and `X-VG-Healed-Trace`. Presets are agent settings.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
valguard/__init__.py,sha256=1P8cIULGeT7i-7gEaxhgPJwrvKg93_FsdeQeXVesDG4,1506
|
|
2
|
+
valguard/client.py,sha256=o1YPeuBAu2KSDE24Bp_NtRc5-htpp3KmSW2EVMO9xOw,10455
|
|
3
|
+
valguard/constants.py,sha256=yeFbbFQQPtyExivMs6j54cN2MpeQj7p3xFKjmBYFWCQ,169
|
|
4
|
+
valguard/errors.py,sha256=3IR-AAEb4QBcvqwfZcnQ-NXry2ZZjSXxNY6v4k2WFCg,1659
|
|
5
|
+
valguard/evidence.py,sha256=og-BGT_6CT9-jDrvB9zZ33BCuRmHjjiH0F2Y79-45E0,1722
|
|
6
|
+
valguard/headers.py,sha256=ifJHIkK9ocvfQXcgoOTqMS3MeF8autKv2-i3FCNyh0g,4667
|
|
7
|
+
valguard/webhook.py,sha256=QoSPiFM3KNCjEX9g82xuzQFdYnoGig-gH2Gc7pHQuwk,1924
|
|
8
|
+
valguard_sdk-0.3.1.dist-info/licenses/LICENSE,sha256=sIaNvTxJRMsQ8ooaahKQdalenIj6U107Fzy1zkRI2XE,1065
|
|
9
|
+
valguard_sdk-0.3.1.dist-info/METADATA,sha256=appZOE7N0eWUVFNjRIm_iPJy4RFNN2QbiqorXWU3aZU,4342
|
|
10
|
+
valguard_sdk-0.3.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
valguard_sdk-0.3.1.dist-info/top_level.txt,sha256=BjdBbeksc7mnv3E4vg6h5nUkJeDgWaaawerKb06Tg3U,9
|
|
12
|
+
valguard_sdk-0.3.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ValGuard
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
valguard
|