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 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
@@ -0,0 +1,4 @@
1
+ """Product URLs — override at runtime via VG_PROXY / VG_API_KEY env vars."""
2
+
3
+ PRODUCT_SITE = "https://valguard.ai"
4
+ DOCS_QUICKSTART = f"{PRODUCT_SITE}/docs/quickstart"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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