verdifax 0.2.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.
verdifax/__init__.py ADDED
@@ -0,0 +1,147 @@
1
+ """Verdifax — cryptographic attestation for AI inference.
2
+
3
+ Three-line integration:
4
+
5
+ >>> import verdifax
6
+ >>> receipt = verdifax.attest(
7
+ ... payload="hello verdifax",
8
+ ... program_id="a" * 64,
9
+ ... route_id="route-test",
10
+ ... registry_record_hash="b" * 64,
11
+ ... ) # doctest: +SKIP
12
+ >>> receipt.manifest_hash # doctest: +SKIP
13
+ '7f...'
14
+
15
+ The SDK targets the Verdifax orchestrator REST API. By default it talks
16
+ to ``http://localhost:9090``; override with ``$VERDIFAX_API_URL`` or by
17
+ passing ``base_url`` to :class:`VerdifaxClient`.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Optional as _Optional, Union as _Union
23
+
24
+ from ._version import __version__
25
+ from .async_client import AsyncVerdifaxClient
26
+ from .client import VerdifaxClient
27
+ from .exceptions import (
28
+ APIError,
29
+ ConnectionError,
30
+ StageError,
31
+ ValidationError,
32
+ VerdifaxError,
33
+ )
34
+ from .helpers import attest_claude_response, attest_openai_response
35
+ from .models import (
36
+ KERNEL_NAMES,
37
+ STAGE_NAMES,
38
+ AttestationReceipt,
39
+ AttestedContext,
40
+ ExecuteRequest,
41
+ ExecutionManifest,
42
+ StageResult,
43
+ )
44
+ from . import section0 # §0 cross-language canonical hash verifier
45
+
46
+
47
+ def from_env(
48
+ base_url: _Optional[str] = None,
49
+ api_key: _Optional[str] = None,
50
+ timeout: _Optional[float] = None,
51
+ ) -> VerdifaxClient:
52
+ """Construct a :class:`VerdifaxClient` from environment variables.
53
+
54
+ Honors ``VERDIFAX_API_URL`` and ``VERDIFAX_API_KEY``. Any explicit
55
+ arguments override the corresponding environment variable.
56
+ """
57
+ kwargs: dict[str, object] = {}
58
+ if base_url is not None:
59
+ kwargs["base_url"] = base_url
60
+ if api_key is not None:
61
+ kwargs["api_key"] = api_key
62
+ if timeout is not None:
63
+ kwargs["timeout"] = timeout
64
+ return VerdifaxClient(**kwargs) # type: ignore[arg-type]
65
+
66
+
67
+ def attest(
68
+ payload: _Union[str, bytes],
69
+ program_id: str,
70
+ route_id: str,
71
+ registry_record_hash: str,
72
+ *,
73
+ attested_context: _Optional[AttestedContext] = None,
74
+ base_url: _Optional[str] = None,
75
+ api_key: _Optional[str] = None,
76
+ timeout: _Optional[float] = None,
77
+ ) -> AttestationReceipt:
78
+ """Module-level shortcut: attest a payload using a one-shot client.
79
+
80
+ Equivalent to ``VerdifaxClient(...).attest(...)`` with the client
81
+ closed automatically afterwards. For high-throughput use, instantiate
82
+ a :class:`VerdifaxClient` yourself and reuse its connection pool.
83
+
84
+ Pass ``attested_context`` to record a caller-attested actor / model /
85
+ decision block into the run's EPA artifact — see
86
+ :class:`AttestedContext`.
87
+ """
88
+ with from_env(base_url=base_url, api_key=api_key, timeout=timeout) as client:
89
+ return client.attest(
90
+ payload=payload,
91
+ program_id=program_id,
92
+ route_id=route_id,
93
+ registry_record_hash=registry_record_hash,
94
+ attested_context=attested_context,
95
+ )
96
+
97
+
98
+ def verify(
99
+ manifest_hash: str,
100
+ payload: _Union[str, bytes],
101
+ program_id: str,
102
+ route_id: str,
103
+ registry_record_hash: str,
104
+ *,
105
+ attested_context: _Optional[AttestedContext] = None,
106
+ base_url: _Optional[str] = None,
107
+ api_key: _Optional[str] = None,
108
+ timeout: _Optional[float] = None,
109
+ ) -> bool:
110
+ """Module-level shortcut: verify a previously issued manifest hash.
111
+
112
+ ``attested_context`` must match the value used on the original
113
+ ``attest`` call.
114
+ """
115
+ with from_env(base_url=base_url, api_key=api_key, timeout=timeout) as client:
116
+ return client.verify(
117
+ manifest_hash=manifest_hash,
118
+ payload=payload,
119
+ program_id=program_id,
120
+ route_id=route_id,
121
+ registry_record_hash=registry_record_hash,
122
+ attested_context=attested_context,
123
+ )
124
+
125
+
126
+ __all__ = [
127
+ "APIError",
128
+ "AsyncVerdifaxClient",
129
+ "AttestationReceipt",
130
+ "AttestedContext",
131
+ "ConnectionError",
132
+ "ExecuteRequest",
133
+ "ExecutionManifest",
134
+ "KERNEL_NAMES",
135
+ "STAGE_NAMES",
136
+ "StageError",
137
+ "StageResult",
138
+ "ValidationError",
139
+ "VerdifaxClient",
140
+ "VerdifaxError",
141
+ "__version__",
142
+ "attest",
143
+ "attest_claude_response",
144
+ "attest_openai_response",
145
+ "from_env",
146
+ "verify",
147
+ ]
verdifax/_transport.py ADDED
@@ -0,0 +1,61 @@
1
+ """Shared response handling for sync and async clients.
2
+
3
+ Centralizing this logic keeps :class:`VerdifaxClient` and
4
+ :class:`AsyncVerdifaxClient` from duplicating error-translation code.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Optional
10
+
11
+ import httpx
12
+
13
+ from .exceptions import APIError, ConnectionError as VerdifaxConnectionError, StageError
14
+
15
+
16
+ def parse_response(response: httpx.Response) -> dict[str, Any]:
17
+ """Validate an HTTP response and return its decoded JSON body.
18
+
19
+ Raises :class:`StageError` for orchestrator stage failures (HTTP 422
20
+ with an ``error_stage`` in the body), :class:`APIError` for any other
21
+ non-2xx response, and returns the JSON body on success.
22
+ """
23
+ try:
24
+ body: dict[str, Any] = response.json()
25
+ except ValueError:
26
+ body = {}
27
+
28
+ if response.is_success:
29
+ return body
30
+
31
+ message = _extract_message(body) or response.reason_phrase or "request failed"
32
+ stage = body.get("error_stage") if isinstance(body, dict) else None
33
+ if response.status_code == 422 and stage:
34
+ raise StageError(
35
+ message=message,
36
+ status_code=response.status_code,
37
+ stage=str(stage),
38
+ response_body=body if isinstance(body, dict) else None,
39
+ )
40
+ raise APIError(
41
+ message=message,
42
+ status_code=response.status_code,
43
+ response_body=body if isinstance(body, dict) else None,
44
+ )
45
+
46
+
47
+ def _extract_message(body: Any) -> Optional[str]:
48
+ if isinstance(body, dict):
49
+ for key in ("error", "message", "detail"):
50
+ value = body.get(key)
51
+ if isinstance(value, str) and value:
52
+ return value
53
+ return None
54
+
55
+
56
+ def wrap_transport_error(exc: httpx.HTTPError) -> VerdifaxConnectionError:
57
+ """Translate an httpx transport error into a Verdifax ``ConnectionError``."""
58
+ return VerdifaxConnectionError(f"verdifax: failed to reach API: {exc}")
59
+
60
+
61
+ __all__ = ["parse_response", "wrap_transport_error"]
@@ -0,0 +1,61 @@
1
+ """Client-side validation helpers shared by the sync and async clients.
2
+
3
+ These mirror the validation the Verdifax API performs server-side (see
4
+ ``cmd/api/main.go``). Performing the check client-side gives callers an
5
+ immediate :class:`~verdifax.exceptions.ValidationError` instead of an
6
+ HTTP 400 round-trip.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Union
12
+
13
+ from .exceptions import ValidationError
14
+
15
+ _HEX_CHARS = frozenset("0123456789abcdef")
16
+
17
+
18
+ def validate_hex64(value: str, field_name: str) -> None:
19
+ """Raise ``ValidationError`` unless ``value`` is 64 lowercase hex chars."""
20
+ if not isinstance(value, str):
21
+ raise ValidationError(f"{field_name} must be a string, got {type(value).__name__}")
22
+ if len(value) != 64:
23
+ raise ValidationError(
24
+ f"{field_name} must be 64-char hex, got {len(value)} chars"
25
+ )
26
+ lowered = value.lower()
27
+ if lowered != value:
28
+ raise ValidationError(f"{field_name} must be lowercase hex")
29
+ for c in lowered:
30
+ if c not in _HEX_CHARS:
31
+ raise ValidationError(f"{field_name} must be lowercase hex (offending char: {c!r})")
32
+
33
+
34
+ def validate_route_id(value: str) -> None:
35
+ """Raise ``ValidationError`` unless ``value`` is a non-empty string."""
36
+ if not isinstance(value, str) or not value:
37
+ raise ValidationError("route_id must be a non-empty string")
38
+
39
+
40
+ def normalize_payload(payload: Union[str, bytes]) -> tuple[str, str]:
41
+ """Return ``(payload_text, payload_b64)`` exactly one of which is non-empty.
42
+
43
+ Strings are sent as ``payload_text``. Bytes are decoded as UTF-8 if
44
+ possible so the API can hash them deterministically; otherwise they are
45
+ base64-encoded into the ``payload`` field.
46
+ """
47
+ if isinstance(payload, str):
48
+ return payload, ""
49
+ if isinstance(payload, bytes):
50
+ try:
51
+ return payload.decode("utf-8"), ""
52
+ except UnicodeDecodeError:
53
+ import base64
54
+
55
+ return "", base64.b64encode(payload).decode("ascii")
56
+ raise ValidationError(
57
+ f"payload must be str or bytes, got {type(payload).__name__}"
58
+ )
59
+
60
+
61
+ __all__ = ["normalize_payload", "validate_hex64", "validate_route_id"]
verdifax/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,208 @@
1
+ """Asynchronous Verdifax API client.
2
+
3
+ Mirrors :class:`verdifax.VerdifaxClient` but uses ``httpx.AsyncClient``.
4
+
5
+ Example:
6
+ >>> import asyncio
7
+ >>> from verdifax import AsyncVerdifaxClient
8
+ >>>
9
+ >>> async def main():
10
+ ... async with AsyncVerdifaxClient() as client:
11
+ ... receipt = await client.attest(
12
+ ... payload="hello verdifax",
13
+ ... program_id="a" * 64,
14
+ ... route_id="route-test",
15
+ ... registry_record_hash="b" * 64,
16
+ ... )
17
+ ... return receipt.manifest_hash
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ from types import TracebackType
24
+ from typing import Any, Optional, Type, Union
25
+
26
+ import httpx
27
+
28
+ from ._transport import parse_response, wrap_transport_error
29
+ from ._validation import normalize_payload, validate_hex64, validate_route_id
30
+ from ._version import __version__
31
+ from .client import (
32
+ DEFAULT_BASE_URL,
33
+ DEFAULT_TIMEOUT,
34
+ ENV_API_KEY,
35
+ ENV_BASE_URL,
36
+ )
37
+ from .exceptions import APIError
38
+ from .models import (
39
+ AttestationReceipt,
40
+ AttestedContext,
41
+ ExecuteRequest,
42
+ ExecutionManifest,
43
+ )
44
+
45
+
46
+ class AsyncVerdifaxClient:
47
+ """Async client for the Verdifax pipeline REST API.
48
+
49
+ Args:
50
+ base_url: Base URL of the Verdifax API. Falls back to
51
+ ``$VERDIFAX_API_URL`` and finally to ``http://localhost:9090``.
52
+ api_key: Optional API key sent as the ``X-Verdifax-Key`` header.
53
+ Falls back to ``$VERDIFAX_API_KEY``.
54
+ timeout: Request timeout in seconds. Defaults to 30.
55
+ transport: Optional custom ``httpx.AsyncBaseTransport`` for tests.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ base_url: Optional[str] = None,
61
+ api_key: Optional[str] = None,
62
+ timeout: float = DEFAULT_TIMEOUT,
63
+ transport: Optional[httpx.AsyncBaseTransport] = None,
64
+ ) -> None:
65
+ self.base_url = (base_url or os.environ.get(ENV_BASE_URL) or DEFAULT_BASE_URL).rstrip("/")
66
+ self.api_key = api_key or os.environ.get(ENV_API_KEY)
67
+ self.timeout = timeout
68
+
69
+ headers = {
70
+ "Content-Type": "application/json",
71
+ "Accept": "application/json",
72
+ "User-Agent": f"verdifax-python/{__version__} (async)",
73
+ }
74
+ if self.api_key:
75
+ headers["X-Verdifax-Key"] = self.api_key
76
+
77
+ self._http = httpx.AsyncClient(
78
+ base_url=self.base_url,
79
+ timeout=timeout,
80
+ headers=headers,
81
+ transport=transport,
82
+ )
83
+
84
+ # ── lifecycle ──────────────────────────────────────────────────────────
85
+ async def close(self) -> None:
86
+ """Release the underlying HTTP connection pool."""
87
+ await self._http.aclose()
88
+
89
+ async def __aenter__(self) -> "AsyncVerdifaxClient":
90
+ return self
91
+
92
+ async def __aexit__(
93
+ self,
94
+ exc_type: Optional[Type[BaseException]],
95
+ exc_val: Optional[BaseException],
96
+ exc_tb: Optional[TracebackType],
97
+ ) -> None:
98
+ await self.close()
99
+
100
+ # ── public API ─────────────────────────────────────────────────────────
101
+ async def health(self) -> dict[str, Any]:
102
+ """Liveness check. Returns the JSON body of ``GET /health``."""
103
+ try:
104
+ response = await self._http.get("/health")
105
+ except httpx.HTTPError as exc:
106
+ raise wrap_transport_error(exc) from exc
107
+ return parse_response(response)
108
+
109
+ async def attest(
110
+ self,
111
+ payload: Union[str, bytes],
112
+ program_id: str,
113
+ route_id: str,
114
+ registry_record_hash: str,
115
+ attested_context: Optional[AttestedContext] = None,
116
+ ) -> AttestationReceipt:
117
+ """Async version of :meth:`VerdifaxClient.attest`.
118
+
119
+ Accepts the same optional ``attested_context`` block —
120
+ see :class:`verdifax.models.AttestedContext`.
121
+ """
122
+ request_body = self._build_execute_body(
123
+ payload=payload,
124
+ program_id=program_id,
125
+ route_id=route_id,
126
+ registry_record_hash=registry_record_hash,
127
+ attested_context=attested_context,
128
+ )
129
+ try:
130
+ response = await self._http.post("/execute", json=request_body)
131
+ except httpx.HTTPError as exc:
132
+ raise wrap_transport_error(exc) from exc
133
+ body = parse_response(response)
134
+ if "manifest" not in body:
135
+ raise APIError(
136
+ message="API response missing 'manifest'",
137
+ status_code=response.status_code,
138
+ response_body=body,
139
+ )
140
+ return AttestationReceipt.from_api_response(body)
141
+
142
+ async def verify(
143
+ self,
144
+ manifest_hash: str,
145
+ payload: Union[str, bytes],
146
+ program_id: str,
147
+ route_id: str,
148
+ registry_record_hash: str,
149
+ attested_context: Optional[AttestedContext] = None,
150
+ ) -> bool:
151
+ """Async version of :meth:`VerdifaxClient.verify`."""
152
+ validate_hex64(manifest_hash, "manifest_hash")
153
+ receipt = await self.attest(
154
+ payload=payload,
155
+ program_id=program_id,
156
+ route_id=route_id,
157
+ registry_record_hash=registry_record_hash,
158
+ attested_context=attested_context,
159
+ )
160
+ return receipt.manifest_hash == manifest_hash
161
+
162
+ async def execute(
163
+ self,
164
+ payload: Union[str, bytes],
165
+ program_id: str,
166
+ route_id: str,
167
+ registry_record_hash: str,
168
+ attested_context: Optional[AttestedContext] = None,
169
+ ) -> ExecutionManifest:
170
+ """Async lower-level alternative returning just the manifest."""
171
+ receipt = await self.attest(
172
+ payload=payload,
173
+ program_id=program_id,
174
+ route_id=route_id,
175
+ registry_record_hash=registry_record_hash,
176
+ attested_context=attested_context,
177
+ )
178
+ return receipt.manifest
179
+
180
+ # ── internal helpers ───────────────────────────────────────────────────
181
+ @staticmethod
182
+ def _build_execute_body(
183
+ payload: Union[str, bytes],
184
+ program_id: str,
185
+ route_id: str,
186
+ registry_record_hash: str,
187
+ attested_context: Optional[AttestedContext] = None,
188
+ ) -> dict[str, Any]:
189
+ validate_hex64(program_id, "program_id")
190
+ validate_hex64(registry_record_hash, "registry_record_hash")
191
+ validate_route_id(route_id)
192
+ payload_text, payload_b64 = normalize_payload(payload)
193
+ # Auto-derive `attested = True` if any field carries a value, so
194
+ # callers don't have to remember to set the boolean explicitly.
195
+ if attested_context is not None:
196
+ attested_context = attested_context.with_auto_attested()
197
+ request = ExecuteRequest(
198
+ payload_text=payload_text or None,
199
+ payload=payload_b64 or None,
200
+ program_id=program_id,
201
+ route_id=route_id,
202
+ registry_record_hash=registry_record_hash,
203
+ attested_context=attested_context,
204
+ )
205
+ return request.model_dump_request()
206
+
207
+
208
+ __all__ = ["AsyncVerdifaxClient"]