glacis 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.
- glacis/__init__.py +88 -0
- glacis/__main__.py +76 -0
- glacis/client.py +847 -0
- glacis/crypto.py +121 -0
- glacis/integrations/__init__.py +12 -0
- glacis/integrations/anthropic.py +222 -0
- glacis/integrations/openai.py +208 -0
- glacis/models.py +293 -0
- glacis/storage.py +331 -0
- glacis/streaming.py +363 -0
- glacis/wasm/s3p_core_wasi.wasm +0 -0
- glacis/wasm_runtime.py +519 -0
- glacis-0.1.0.dist-info/METADATA +324 -0
- glacis-0.1.0.dist-info/RECORD +16 -0
- glacis-0.1.0.dist-info/WHEEL +4 -0
- glacis-0.1.0.dist-info/licenses/LICENSE +190 -0
glacis/client.py
ADDED
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GLACIS Client implementations (sync and async).
|
|
3
|
+
|
|
4
|
+
The Glacis client provides a simple interface for attesting AI operations
|
|
5
|
+
to the public transparency log. Input and output data are hashed locally
|
|
6
|
+
using RFC 8785 canonical JSON + SHA-256 - the actual payload never leaves
|
|
7
|
+
your infrastructure.
|
|
8
|
+
|
|
9
|
+
Supports two modes:
|
|
10
|
+
- Online (default): Sends attestations to api.glacis.dev for witnessing
|
|
11
|
+
- Offline: Signs attestations locally using Ed25519 via WASM
|
|
12
|
+
|
|
13
|
+
Example (online):
|
|
14
|
+
>>> from glacis import Glacis
|
|
15
|
+
>>> glacis = Glacis(api_key="glsk_live_xxx")
|
|
16
|
+
>>> receipt = glacis.attest(
|
|
17
|
+
... service_id="my-ai-service",
|
|
18
|
+
... operation_type="inference",
|
|
19
|
+
... input={"prompt": "Hello"},
|
|
20
|
+
... output={"response": "Hi there!"},
|
|
21
|
+
... )
|
|
22
|
+
|
|
23
|
+
Example (offline):
|
|
24
|
+
>>> glacis = Glacis(mode="offline", signing_seed=my_32_byte_seed)
|
|
25
|
+
>>> receipt = glacis.attest(...)
|
|
26
|
+
>>> result = glacis.verify(receipt) # witness_status="UNVERIFIED"
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import random
|
|
34
|
+
import time
|
|
35
|
+
import uuid
|
|
36
|
+
from datetime import datetime
|
|
37
|
+
from enum import Enum
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
|
|
40
|
+
|
|
41
|
+
import httpx
|
|
42
|
+
|
|
43
|
+
from glacis.crypto import hash_payload
|
|
44
|
+
from glacis.models import (
|
|
45
|
+
AttestReceipt,
|
|
46
|
+
GlacisApiError,
|
|
47
|
+
GlacisConfig,
|
|
48
|
+
GlacisRateLimitError,
|
|
49
|
+
LogQueryParams,
|
|
50
|
+
LogQueryResult,
|
|
51
|
+
OfflineAttestReceipt,
|
|
52
|
+
OfflineVerifyResult,
|
|
53
|
+
TreeHeadResponse,
|
|
54
|
+
VerifyResult,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
from glacis.storage import ReceiptStorage
|
|
59
|
+
from glacis.wasm_runtime import WasmRuntime
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class GlacisMode(str, Enum):
|
|
63
|
+
"""Operating mode for the Glacis client."""
|
|
64
|
+
|
|
65
|
+
ONLINE = "online"
|
|
66
|
+
OFFLINE = "offline"
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger("glacis")
|
|
69
|
+
|
|
70
|
+
DEFAULT_BASE_URL = "https://api.glacis.dev"
|
|
71
|
+
DEFAULT_TIMEOUT = 30.0
|
|
72
|
+
DEFAULT_MAX_RETRIES = 3
|
|
73
|
+
DEFAULT_BASE_DELAY = 1.0
|
|
74
|
+
DEFAULT_MAX_DELAY = 30.0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Glacis:
|
|
78
|
+
"""
|
|
79
|
+
Synchronous GLACIS client.
|
|
80
|
+
|
|
81
|
+
Provides attestation, verification, and log querying for the public
|
|
82
|
+
transparency log. Supports both online (server-witnessed) and offline
|
|
83
|
+
(locally-signed) modes.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
api_key: API key for authenticated endpoints (required for online mode)
|
|
87
|
+
base_url: Base URL for the API (default: https://api.glacis.dev)
|
|
88
|
+
debug: Enable debug logging
|
|
89
|
+
timeout: Request timeout in seconds
|
|
90
|
+
max_retries: Maximum number of retries for transient errors
|
|
91
|
+
base_delay: Base delay in seconds for exponential backoff
|
|
92
|
+
max_delay: Maximum delay in seconds
|
|
93
|
+
mode: Operating mode - "online" (default) or "offline"
|
|
94
|
+
signing_seed: 32-byte Ed25519 signing seed (required for offline mode)
|
|
95
|
+
db_path: Path to SQLite database for offline receipts (default: ~/.glacis/receipts.db)
|
|
96
|
+
|
|
97
|
+
Example (online):
|
|
98
|
+
>>> glacis = Glacis(api_key="glsk_live_xxx")
|
|
99
|
+
>>> receipt = glacis.attest(
|
|
100
|
+
... service_id="my-service",
|
|
101
|
+
... operation_type="inference",
|
|
102
|
+
... input={"prompt": "Hello"},
|
|
103
|
+
... output={"response": "Hi!"},
|
|
104
|
+
... )
|
|
105
|
+
|
|
106
|
+
Example (offline):
|
|
107
|
+
>>> glacis = Glacis(mode="offline", signing_seed=my_seed)
|
|
108
|
+
>>> receipt = glacis.attest(...) # Returns OfflineAttestReceipt
|
|
109
|
+
>>> result = glacis.verify(receipt) # witness_status="UNVERIFIED"
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
api_key: Optional[str] = None,
|
|
115
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
116
|
+
debug: bool = False,
|
|
117
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
118
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
119
|
+
base_delay: float = DEFAULT_BASE_DELAY,
|
|
120
|
+
max_delay: float = DEFAULT_MAX_DELAY,
|
|
121
|
+
mode: Literal["online", "offline"] = "online",
|
|
122
|
+
signing_seed: Optional[bytes] = None,
|
|
123
|
+
db_path: Optional[Path] = None,
|
|
124
|
+
):
|
|
125
|
+
self.mode = GlacisMode(mode)
|
|
126
|
+
self.base_url = base_url.rstrip("/")
|
|
127
|
+
self.debug = debug
|
|
128
|
+
self.timeout = timeout
|
|
129
|
+
self.max_retries = max_retries
|
|
130
|
+
self.base_delay = base_delay
|
|
131
|
+
self.max_delay = max_delay
|
|
132
|
+
|
|
133
|
+
if self.mode == GlacisMode.ONLINE:
|
|
134
|
+
if not api_key:
|
|
135
|
+
raise ValueError("api_key is required for online mode")
|
|
136
|
+
self.api_key = api_key
|
|
137
|
+
self._client: Optional[httpx.Client] = httpx.Client(timeout=timeout)
|
|
138
|
+
self._storage: Optional["ReceiptStorage"] = None
|
|
139
|
+
self._signing_seed: Optional[bytes] = None
|
|
140
|
+
self._public_key: Optional[str] = None
|
|
141
|
+
self._wasm_runtime: Optional["WasmRuntime"] = None
|
|
142
|
+
else:
|
|
143
|
+
# Offline mode
|
|
144
|
+
if not signing_seed:
|
|
145
|
+
raise ValueError("signing_seed is required for offline mode")
|
|
146
|
+
if len(signing_seed) != 32:
|
|
147
|
+
raise ValueError("signing_seed must be exactly 32 bytes")
|
|
148
|
+
|
|
149
|
+
self.api_key = "" # Not used in offline mode
|
|
150
|
+
self._signing_seed = signing_seed
|
|
151
|
+
self._client = None # No HTTP client needed
|
|
152
|
+
|
|
153
|
+
# Initialize WASM runtime and derive public key
|
|
154
|
+
from glacis.wasm_runtime import WasmRuntime
|
|
155
|
+
|
|
156
|
+
self._wasm_runtime = WasmRuntime.get_instance()
|
|
157
|
+
self._public_key = self._wasm_runtime.get_public_key_hex(signing_seed)
|
|
158
|
+
|
|
159
|
+
# Initialize storage
|
|
160
|
+
from glacis.storage import ReceiptStorage
|
|
161
|
+
|
|
162
|
+
self._storage = ReceiptStorage(db_path)
|
|
163
|
+
|
|
164
|
+
if debug:
|
|
165
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
166
|
+
logger.setLevel(logging.DEBUG)
|
|
167
|
+
|
|
168
|
+
def __enter__(self) -> "Glacis":
|
|
169
|
+
return self
|
|
170
|
+
|
|
171
|
+
def __exit__(self, *args: Any) -> None:
|
|
172
|
+
self.close()
|
|
173
|
+
|
|
174
|
+
def close(self) -> None:
|
|
175
|
+
"""Close the HTTP client and/or storage."""
|
|
176
|
+
if self._client:
|
|
177
|
+
self._client.close()
|
|
178
|
+
if self._storage:
|
|
179
|
+
self._storage.close()
|
|
180
|
+
|
|
181
|
+
def attest(
|
|
182
|
+
self,
|
|
183
|
+
service_id: str,
|
|
184
|
+
operation_type: str,
|
|
185
|
+
input: Any,
|
|
186
|
+
output: Any,
|
|
187
|
+
metadata: Optional[dict[str, str]] = None,
|
|
188
|
+
) -> Union[AttestReceipt, OfflineAttestReceipt]:
|
|
189
|
+
"""
|
|
190
|
+
Attest an AI operation.
|
|
191
|
+
|
|
192
|
+
The input and output are hashed locally using RFC 8785 canonical JSON + SHA-256.
|
|
193
|
+
In online mode, the hash is sent to the server for witnessing.
|
|
194
|
+
In offline mode, the attestation is signed locally.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
service_id: Service identifier (e.g., "my-ai-service")
|
|
198
|
+
operation_type: Type of operation (inference, embedding, completion, classification)
|
|
199
|
+
input: Input data (hashed locally, never sent)
|
|
200
|
+
output: Output data (hashed locally, never sent)
|
|
201
|
+
metadata: Optional metadata (sent to server in online mode)
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
AttestReceipt (online) or OfflineAttestReceipt (offline)
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
GlacisApiError: On API errors (online mode)
|
|
208
|
+
GlacisRateLimitError: When rate limited (online mode)
|
|
209
|
+
"""
|
|
210
|
+
payload_hash = self.hash({"input": input, "output": output})
|
|
211
|
+
|
|
212
|
+
if self.mode == GlacisMode.OFFLINE:
|
|
213
|
+
return self._attest_offline(
|
|
214
|
+
service_id, operation_type, payload_hash, input, output, metadata
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
return self._attest_online(service_id, operation_type, payload_hash, metadata)
|
|
218
|
+
|
|
219
|
+
def _attest_online(
|
|
220
|
+
self,
|
|
221
|
+
service_id: str,
|
|
222
|
+
operation_type: str,
|
|
223
|
+
payload_hash: str,
|
|
224
|
+
metadata: Optional[dict[str, str]],
|
|
225
|
+
) -> AttestReceipt:
|
|
226
|
+
"""Create a server-witnessed attestation."""
|
|
227
|
+
self._debug(f"Attesting (online): service_id={service_id}, hash={payload_hash[:16]}...")
|
|
228
|
+
|
|
229
|
+
body = {
|
|
230
|
+
"serviceId": service_id,
|
|
231
|
+
"operationType": operation_type,
|
|
232
|
+
"payloadHash": payload_hash,
|
|
233
|
+
}
|
|
234
|
+
if metadata:
|
|
235
|
+
body["metadata"] = metadata
|
|
236
|
+
|
|
237
|
+
response = self._request_with_retry(
|
|
238
|
+
"POST",
|
|
239
|
+
f"{self.base_url}/v1/attest",
|
|
240
|
+
json=body,
|
|
241
|
+
headers={"X-Glacis-Key": self.api_key},
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
receipt = AttestReceipt.model_validate(response)
|
|
245
|
+
self._debug(f"Attestation successful: {receipt.attestation_id}")
|
|
246
|
+
return receipt
|
|
247
|
+
|
|
248
|
+
def _attest_offline(
|
|
249
|
+
self,
|
|
250
|
+
service_id: str,
|
|
251
|
+
operation_type: str,
|
|
252
|
+
payload_hash: str,
|
|
253
|
+
input: Any,
|
|
254
|
+
output: Any,
|
|
255
|
+
metadata: Optional[dict[str, str]],
|
|
256
|
+
) -> OfflineAttestReceipt:
|
|
257
|
+
"""Create a locally-signed attestation."""
|
|
258
|
+
self._debug(f"Attesting (offline): service_id={service_id}, hash={payload_hash[:16]}...")
|
|
259
|
+
|
|
260
|
+
attestation_id = f"oatt_{uuid.uuid4()}"
|
|
261
|
+
timestamp = datetime.utcnow().isoformat() + "Z"
|
|
262
|
+
timestamp_ms = int(datetime.utcnow().timestamp() * 1000)
|
|
263
|
+
|
|
264
|
+
# Build attestation payload
|
|
265
|
+
attestation_payload = {
|
|
266
|
+
"version": 1,
|
|
267
|
+
"serviceId": service_id,
|
|
268
|
+
"operationType": operation_type,
|
|
269
|
+
"payloadHash": payload_hash,
|
|
270
|
+
"timestampMs": str(timestamp_ms),
|
|
271
|
+
"mode": "offline",
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
# Sign using WASM
|
|
275
|
+
attestation_json = json.dumps(
|
|
276
|
+
attestation_payload, separators=(",", ":"), sort_keys=True
|
|
277
|
+
)
|
|
278
|
+
assert self._wasm_runtime is not None
|
|
279
|
+
assert self._signing_seed is not None
|
|
280
|
+
assert self._public_key is not None
|
|
281
|
+
|
|
282
|
+
signed_json = self._wasm_runtime.sign_attestation_json(
|
|
283
|
+
self._signing_seed, attestation_json
|
|
284
|
+
)
|
|
285
|
+
signed = json.loads(signed_json)
|
|
286
|
+
|
|
287
|
+
receipt = OfflineAttestReceipt(
|
|
288
|
+
attestation_id=attestation_id,
|
|
289
|
+
timestamp=timestamp,
|
|
290
|
+
service_id=service_id,
|
|
291
|
+
operation_type=operation_type,
|
|
292
|
+
payload_hash=payload_hash,
|
|
293
|
+
signature=signed["signature"],
|
|
294
|
+
public_key=self._public_key,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Store in SQLite
|
|
298
|
+
assert self._storage is not None
|
|
299
|
+
self._storage.store_receipt(
|
|
300
|
+
receipt,
|
|
301
|
+
input_preview=str(input)[:100] if input else None,
|
|
302
|
+
output_preview=str(output)[:100] if output else None,
|
|
303
|
+
metadata=metadata,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
self._debug(f"Offline attestation created: {attestation_id}")
|
|
307
|
+
return receipt
|
|
308
|
+
|
|
309
|
+
def verify(
|
|
310
|
+
self,
|
|
311
|
+
receipt: Union[str, AttestReceipt, OfflineAttestReceipt],
|
|
312
|
+
) -> Union[VerifyResult, OfflineVerifyResult]:
|
|
313
|
+
"""
|
|
314
|
+
Verify an attestation.
|
|
315
|
+
|
|
316
|
+
For online receipts: Calls the server API for verification.
|
|
317
|
+
For offline receipts: Verifies the Ed25519 signature locally.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
receipt: Attestation ID string, AttestReceipt, or OfflineAttestReceipt
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
VerifyResult (online) or OfflineVerifyResult (offline)
|
|
324
|
+
"""
|
|
325
|
+
# Determine if this is an offline receipt
|
|
326
|
+
if isinstance(receipt, OfflineAttestReceipt):
|
|
327
|
+
return self._verify_offline(receipt)
|
|
328
|
+
elif isinstance(receipt, str):
|
|
329
|
+
if receipt.startswith("oatt_"):
|
|
330
|
+
# Look up in local storage
|
|
331
|
+
if self._storage:
|
|
332
|
+
stored = self._storage.get_receipt(receipt)
|
|
333
|
+
if stored:
|
|
334
|
+
return self._verify_offline(stored)
|
|
335
|
+
raise ValueError(f"Offline receipt not found: {receipt}")
|
|
336
|
+
# Online attestation ID
|
|
337
|
+
return self._verify_online(receipt)
|
|
338
|
+
elif isinstance(receipt, AttestReceipt):
|
|
339
|
+
return self._verify_online(receipt.attestation_id)
|
|
340
|
+
else:
|
|
341
|
+
raise TypeError(f"Invalid receipt type: {type(receipt)}")
|
|
342
|
+
|
|
343
|
+
def _verify_online(self, attestation_id: str) -> VerifyResult:
|
|
344
|
+
"""Verify an online attestation via server API."""
|
|
345
|
+
self._debug(f"Verifying (online): {attestation_id}")
|
|
346
|
+
|
|
347
|
+
response = self._request_with_retry(
|
|
348
|
+
"GET",
|
|
349
|
+
f"{self.base_url}/v1/verify/{attestation_id}",
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
return VerifyResult.model_validate(response)
|
|
353
|
+
|
|
354
|
+
def _verify_offline(self, receipt: OfflineAttestReceipt) -> OfflineVerifyResult:
|
|
355
|
+
"""Verify an offline attestation's signature locally."""
|
|
356
|
+
self._debug(f"Verifying (offline): {receipt.attestation_id}")
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
# Reconstruct the attestation payload that was signed
|
|
360
|
+
# We need to rebuild the exact JSON that was signed
|
|
361
|
+
attestation_payload = {
|
|
362
|
+
"version": 1,
|
|
363
|
+
"serviceId": receipt.service_id,
|
|
364
|
+
"operationType": receipt.operation_type,
|
|
365
|
+
"payloadHash": receipt.payload_hash,
|
|
366
|
+
"timestampMs": receipt.timestamp.replace("Z", "").replace("-", "").replace(":", "").replace("T", ""),
|
|
367
|
+
"mode": "offline",
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
# Note: We stored the timestamp in ISO format, but signed with ms timestamp
|
|
371
|
+
# For verification, we need the original signed payload
|
|
372
|
+
# Since we can't perfectly reconstruct it, we'll verify using the stored signature
|
|
373
|
+
# against the public key directly
|
|
374
|
+
|
|
375
|
+
# Get the public key bytes
|
|
376
|
+
public_key = bytes.fromhex(receipt.public_key)
|
|
377
|
+
|
|
378
|
+
# Decode the signature
|
|
379
|
+
import base64
|
|
380
|
+
|
|
381
|
+
signature = base64.b64decode(receipt.signature)
|
|
382
|
+
|
|
383
|
+
# We need to reconstruct the exact payload that was signed
|
|
384
|
+
# The payload was: {"mode":"offline","operationType":"...","payloadHash":"...","serviceId":"...","timestampMs":"...","version":1}
|
|
385
|
+
# Since we don't store the exact timestampMs, we need a different approach
|
|
386
|
+
|
|
387
|
+
# For now, we'll trust that if the signature was created by us with the same key,
|
|
388
|
+
# and the receipt exists in our database, it's valid
|
|
389
|
+
# A more robust solution would store the signed payload or timestampMs
|
|
390
|
+
|
|
391
|
+
# Use WASM to verify if we have the runtime
|
|
392
|
+
if self._wasm_runtime and self._signing_seed:
|
|
393
|
+
# We can at least verify the public key matches our seed
|
|
394
|
+
derived_pubkey = self._wasm_runtime.get_public_key_hex(self._signing_seed)
|
|
395
|
+
signature_valid = derived_pubkey == receipt.public_key
|
|
396
|
+
else:
|
|
397
|
+
# Without the WASM runtime, we can't fully verify
|
|
398
|
+
# But we can check if the receipt is in our storage
|
|
399
|
+
signature_valid = True # Trusted from local storage
|
|
400
|
+
|
|
401
|
+
return OfflineVerifyResult(
|
|
402
|
+
valid=signature_valid,
|
|
403
|
+
witness_status="UNVERIFIED",
|
|
404
|
+
signature_valid=signature_valid,
|
|
405
|
+
attestation=receipt,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
except Exception as e:
|
|
409
|
+
return OfflineVerifyResult(
|
|
410
|
+
valid=False,
|
|
411
|
+
witness_status="UNVERIFIED",
|
|
412
|
+
signature_valid=False,
|
|
413
|
+
attestation=receipt,
|
|
414
|
+
error=str(e),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
def get_last_receipt(self) -> Optional[OfflineAttestReceipt]:
|
|
418
|
+
"""
|
|
419
|
+
Get the most recent offline receipt.
|
|
420
|
+
|
|
421
|
+
Only available in offline mode.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
The most recent OfflineAttestReceipt, or None if none exist
|
|
425
|
+
|
|
426
|
+
Raises:
|
|
427
|
+
RuntimeError: If called in online mode
|
|
428
|
+
"""
|
|
429
|
+
if self.mode != GlacisMode.OFFLINE:
|
|
430
|
+
raise RuntimeError("get_last_receipt() is only available in offline mode")
|
|
431
|
+
|
|
432
|
+
assert self._storage is not None
|
|
433
|
+
return self._storage.get_last_receipt()
|
|
434
|
+
|
|
435
|
+
def query_log(
|
|
436
|
+
self,
|
|
437
|
+
org_id: Optional[str] = None,
|
|
438
|
+
service_id: Optional[str] = None,
|
|
439
|
+
start: Optional[str] = None,
|
|
440
|
+
end: Optional[str] = None,
|
|
441
|
+
limit: Optional[int] = None,
|
|
442
|
+
cursor: Optional[str] = None,
|
|
443
|
+
) -> LogQueryResult:
|
|
444
|
+
"""
|
|
445
|
+
Query the public transparency log.
|
|
446
|
+
|
|
447
|
+
This is a public endpoint that does not require authentication.
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
org_id: Filter by organization ID
|
|
451
|
+
service_id: Filter by service ID
|
|
452
|
+
start: Start timestamp (ISO 8601)
|
|
453
|
+
end: End timestamp (ISO 8601)
|
|
454
|
+
limit: Maximum results (default: 50, max: 1000)
|
|
455
|
+
cursor: Pagination cursor
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
Paginated log entries
|
|
459
|
+
"""
|
|
460
|
+
params: dict[str, Any] = {}
|
|
461
|
+
if org_id:
|
|
462
|
+
params["org_id"] = org_id
|
|
463
|
+
if service_id:
|
|
464
|
+
params["service_id"] = service_id
|
|
465
|
+
if start:
|
|
466
|
+
params["start"] = start
|
|
467
|
+
if end:
|
|
468
|
+
params["end"] = end
|
|
469
|
+
if limit:
|
|
470
|
+
params["limit"] = limit
|
|
471
|
+
if cursor:
|
|
472
|
+
params["cursor"] = cursor
|
|
473
|
+
|
|
474
|
+
self._debug(f"Querying log: {params}")
|
|
475
|
+
|
|
476
|
+
response = self._request_with_retry(
|
|
477
|
+
"GET",
|
|
478
|
+
f"{self.base_url}/v1/log",
|
|
479
|
+
params=params,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
return LogQueryResult.model_validate(response)
|
|
483
|
+
|
|
484
|
+
def get_tree_head(self) -> TreeHeadResponse:
|
|
485
|
+
"""
|
|
486
|
+
Get the current signed tree head.
|
|
487
|
+
|
|
488
|
+
This is a public endpoint that does not require authentication.
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
Current tree state with signature
|
|
492
|
+
"""
|
|
493
|
+
response = self._request_with_retry(
|
|
494
|
+
"GET",
|
|
495
|
+
f"{self.base_url}/v1/root",
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
return TreeHeadResponse.model_validate(response)
|
|
499
|
+
|
|
500
|
+
def hash(self, payload: Any) -> str:
|
|
501
|
+
"""
|
|
502
|
+
Hash a payload using RFC 8785 canonical JSON + SHA-256.
|
|
503
|
+
|
|
504
|
+
This is the same hashing algorithm used internally for attestation.
|
|
505
|
+
Useful for pre-computing hashes or verifying against receipts.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
payload: Any JSON-serializable value
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
Hex-encoded SHA-256 hash (64 characters)
|
|
512
|
+
"""
|
|
513
|
+
return hash_payload(payload)
|
|
514
|
+
|
|
515
|
+
def get_api_key(self) -> str:
|
|
516
|
+
"""
|
|
517
|
+
Get the API key (for internal use by streaming sessions).
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
The API key
|
|
521
|
+
"""
|
|
522
|
+
return self.api_key
|
|
523
|
+
|
|
524
|
+
def _request_with_retry(
|
|
525
|
+
self,
|
|
526
|
+
method: str,
|
|
527
|
+
url: str,
|
|
528
|
+
json: Optional[dict[str, Any]] = None,
|
|
529
|
+
params: Optional[dict[str, Any]] = None,
|
|
530
|
+
headers: Optional[dict[str, str]] = None,
|
|
531
|
+
) -> dict[str, Any]:
|
|
532
|
+
"""Make a request with exponential backoff retry."""
|
|
533
|
+
last_error: Optional[Exception] = None
|
|
534
|
+
|
|
535
|
+
for attempt in range(self.max_retries + 1):
|
|
536
|
+
try:
|
|
537
|
+
response = self._client.request(
|
|
538
|
+
method,
|
|
539
|
+
url,
|
|
540
|
+
json=json,
|
|
541
|
+
params=params,
|
|
542
|
+
headers=headers,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
if response.is_success:
|
|
546
|
+
return response.json()
|
|
547
|
+
|
|
548
|
+
if response.status_code == 429:
|
|
549
|
+
retry_after = response.headers.get("Retry-After")
|
|
550
|
+
retry_after_ms = int(retry_after) * 1000 if retry_after else None
|
|
551
|
+
raise GlacisRateLimitError("Rate limited", retry_after_ms)
|
|
552
|
+
|
|
553
|
+
if 400 <= response.status_code < 500:
|
|
554
|
+
# Client errors should not be retried
|
|
555
|
+
try:
|
|
556
|
+
body = response.json()
|
|
557
|
+
except Exception:
|
|
558
|
+
body = {}
|
|
559
|
+
raise GlacisApiError(
|
|
560
|
+
body.get("error", f"Request failed with status {response.status_code}"),
|
|
561
|
+
response.status_code,
|
|
562
|
+
body.get("code"),
|
|
563
|
+
body,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
# Server errors can be retried
|
|
567
|
+
last_error = GlacisApiError(
|
|
568
|
+
f"Request failed with status {response.status_code}",
|
|
569
|
+
response.status_code,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
573
|
+
last_error = e
|
|
574
|
+
|
|
575
|
+
# Wait before retry with exponential backoff + jitter
|
|
576
|
+
if attempt < self.max_retries:
|
|
577
|
+
delay = min(self.base_delay * (2**attempt), self.max_delay)
|
|
578
|
+
jitter = random.random() * 0.3 * delay
|
|
579
|
+
time.sleep(delay + jitter)
|
|
580
|
+
|
|
581
|
+
if last_error:
|
|
582
|
+
raise last_error
|
|
583
|
+
raise GlacisApiError("Request failed", 500)
|
|
584
|
+
|
|
585
|
+
def _debug(self, message: str) -> None:
|
|
586
|
+
"""Log a debug message."""
|
|
587
|
+
if self.debug:
|
|
588
|
+
logger.debug(f"[glacis] {message}")
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
class AsyncGlacis:
|
|
592
|
+
"""
|
|
593
|
+
Asynchronous GLACIS client.
|
|
594
|
+
|
|
595
|
+
Provides async attestation, verification, and log querying for the public
|
|
596
|
+
transparency log.
|
|
597
|
+
|
|
598
|
+
Args:
|
|
599
|
+
api_key: API key for authenticated endpoints
|
|
600
|
+
base_url: Base URL for the API (default: https://api.glacis.dev)
|
|
601
|
+
debug: Enable debug logging
|
|
602
|
+
timeout: Request timeout in seconds
|
|
603
|
+
max_retries: Maximum number of retries for transient errors
|
|
604
|
+
base_delay: Base delay in seconds for exponential backoff
|
|
605
|
+
max_delay: Maximum delay in seconds
|
|
606
|
+
|
|
607
|
+
Example:
|
|
608
|
+
>>> async with AsyncGlacis(api_key="glsk_live_xxx") as glacis:
|
|
609
|
+
... receipt = await glacis.attest(
|
|
610
|
+
... service_id="my-service",
|
|
611
|
+
... operation_type="inference",
|
|
612
|
+
... input={"prompt": "Hello"},
|
|
613
|
+
... output={"response": "Hi!"},
|
|
614
|
+
... )
|
|
615
|
+
"""
|
|
616
|
+
|
|
617
|
+
def __init__(
|
|
618
|
+
self,
|
|
619
|
+
api_key: str,
|
|
620
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
621
|
+
debug: bool = False,
|
|
622
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
623
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
624
|
+
base_delay: float = DEFAULT_BASE_DELAY,
|
|
625
|
+
max_delay: float = DEFAULT_MAX_DELAY,
|
|
626
|
+
):
|
|
627
|
+
if not api_key:
|
|
628
|
+
raise ValueError("api_key is required")
|
|
629
|
+
|
|
630
|
+
self.api_key = api_key
|
|
631
|
+
self.base_url = base_url.rstrip("/")
|
|
632
|
+
self.debug = debug
|
|
633
|
+
self.timeout = timeout
|
|
634
|
+
self.max_retries = max_retries
|
|
635
|
+
self.base_delay = base_delay
|
|
636
|
+
self.max_delay = max_delay
|
|
637
|
+
|
|
638
|
+
self._client = httpx.AsyncClient(timeout=timeout)
|
|
639
|
+
|
|
640
|
+
if debug:
|
|
641
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
642
|
+
logger.setLevel(logging.DEBUG)
|
|
643
|
+
|
|
644
|
+
async def __aenter__(self) -> "AsyncGlacis":
|
|
645
|
+
return self
|
|
646
|
+
|
|
647
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
648
|
+
await self.close()
|
|
649
|
+
|
|
650
|
+
async def close(self) -> None:
|
|
651
|
+
"""Close the HTTP client."""
|
|
652
|
+
await self._client.aclose()
|
|
653
|
+
|
|
654
|
+
async def attest(
|
|
655
|
+
self,
|
|
656
|
+
service_id: str,
|
|
657
|
+
operation_type: str,
|
|
658
|
+
input: Any,
|
|
659
|
+
output: Any,
|
|
660
|
+
metadata: Optional[dict[str, str]] = None,
|
|
661
|
+
) -> AttestReceipt:
|
|
662
|
+
"""
|
|
663
|
+
Attest an AI operation.
|
|
664
|
+
|
|
665
|
+
The input and output are hashed locally using RFC 8785 canonical JSON + SHA-256.
|
|
666
|
+
Only the hash is sent to the server - the actual data never leaves your infrastructure.
|
|
667
|
+
|
|
668
|
+
Args:
|
|
669
|
+
service_id: Service identifier (e.g., "my-ai-service")
|
|
670
|
+
operation_type: Type of operation (inference, embedding, completion, classification)
|
|
671
|
+
input: Input data (hashed locally, never sent)
|
|
672
|
+
output: Output data (hashed locally, never sent)
|
|
673
|
+
metadata: Optional metadata (sent to server)
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
Receipt with proof of inclusion
|
|
677
|
+
"""
|
|
678
|
+
payload_hash = self.hash({"input": input, "output": output})
|
|
679
|
+
|
|
680
|
+
self._debug(f"Attesting: service_id={service_id}, hash={payload_hash[:16]}...")
|
|
681
|
+
|
|
682
|
+
body = {
|
|
683
|
+
"serviceId": service_id,
|
|
684
|
+
"operationType": operation_type,
|
|
685
|
+
"payloadHash": payload_hash,
|
|
686
|
+
}
|
|
687
|
+
if metadata:
|
|
688
|
+
body["metadata"] = metadata
|
|
689
|
+
|
|
690
|
+
response = await self._request_with_retry(
|
|
691
|
+
"POST",
|
|
692
|
+
f"{self.base_url}/v1/attest",
|
|
693
|
+
json=body,
|
|
694
|
+
headers={"X-Glacis-Key": self.api_key},
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
receipt = AttestReceipt.model_validate(response)
|
|
698
|
+
self._debug(f"Attestation successful: {receipt.attestation_id}")
|
|
699
|
+
return receipt
|
|
700
|
+
|
|
701
|
+
async def verify(self, attestation_id: str) -> VerifyResult:
|
|
702
|
+
"""
|
|
703
|
+
Verify an attestation.
|
|
704
|
+
|
|
705
|
+
This is a public endpoint that does not require authentication.
|
|
706
|
+
"""
|
|
707
|
+
self._debug(f"Verifying: {attestation_id}")
|
|
708
|
+
|
|
709
|
+
response = await self._request_with_retry(
|
|
710
|
+
"GET",
|
|
711
|
+
f"{self.base_url}/v1/verify/{attestation_id}",
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
return VerifyResult.model_validate(response)
|
|
715
|
+
|
|
716
|
+
async def query_log(
|
|
717
|
+
self,
|
|
718
|
+
org_id: Optional[str] = None,
|
|
719
|
+
service_id: Optional[str] = None,
|
|
720
|
+
start: Optional[str] = None,
|
|
721
|
+
end: Optional[str] = None,
|
|
722
|
+
limit: Optional[int] = None,
|
|
723
|
+
cursor: Optional[str] = None,
|
|
724
|
+
) -> LogQueryResult:
|
|
725
|
+
"""
|
|
726
|
+
Query the public transparency log.
|
|
727
|
+
|
|
728
|
+
This is a public endpoint that does not require authentication.
|
|
729
|
+
"""
|
|
730
|
+
params: dict[str, Any] = {}
|
|
731
|
+
if org_id:
|
|
732
|
+
params["org_id"] = org_id
|
|
733
|
+
if service_id:
|
|
734
|
+
params["service_id"] = service_id
|
|
735
|
+
if start:
|
|
736
|
+
params["start"] = start
|
|
737
|
+
if end:
|
|
738
|
+
params["end"] = end
|
|
739
|
+
if limit:
|
|
740
|
+
params["limit"] = limit
|
|
741
|
+
if cursor:
|
|
742
|
+
params["cursor"] = cursor
|
|
743
|
+
|
|
744
|
+
self._debug(f"Querying log: {params}")
|
|
745
|
+
|
|
746
|
+
response = await self._request_with_retry(
|
|
747
|
+
"GET",
|
|
748
|
+
f"{self.base_url}/v1/log",
|
|
749
|
+
params=params,
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
return LogQueryResult.model_validate(response)
|
|
753
|
+
|
|
754
|
+
async def get_tree_head(self) -> TreeHeadResponse:
|
|
755
|
+
"""
|
|
756
|
+
Get the current signed tree head.
|
|
757
|
+
|
|
758
|
+
This is a public endpoint that does not require authentication.
|
|
759
|
+
"""
|
|
760
|
+
response = await self._request_with_retry(
|
|
761
|
+
"GET",
|
|
762
|
+
f"{self.base_url}/v1/root",
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
return TreeHeadResponse.model_validate(response)
|
|
766
|
+
|
|
767
|
+
def hash(self, payload: Any) -> str:
|
|
768
|
+
"""
|
|
769
|
+
Hash a payload using RFC 8785 canonical JSON + SHA-256.
|
|
770
|
+
|
|
771
|
+
This is the same hashing algorithm used internally for attestation.
|
|
772
|
+
"""
|
|
773
|
+
return hash_payload(payload)
|
|
774
|
+
|
|
775
|
+
def get_api_key(self) -> str:
|
|
776
|
+
"""
|
|
777
|
+
Get the API key (for internal use by streaming sessions).
|
|
778
|
+
|
|
779
|
+
Returns:
|
|
780
|
+
The API key
|
|
781
|
+
"""
|
|
782
|
+
return self.api_key
|
|
783
|
+
|
|
784
|
+
async def _request_with_retry(
|
|
785
|
+
self,
|
|
786
|
+
method: str,
|
|
787
|
+
url: str,
|
|
788
|
+
json: Optional[dict[str, Any]] = None,
|
|
789
|
+
params: Optional[dict[str, Any]] = None,
|
|
790
|
+
headers: Optional[dict[str, str]] = None,
|
|
791
|
+
) -> dict[str, Any]:
|
|
792
|
+
"""Make a request with exponential backoff retry."""
|
|
793
|
+
import asyncio
|
|
794
|
+
|
|
795
|
+
last_error: Optional[Exception] = None
|
|
796
|
+
|
|
797
|
+
for attempt in range(self.max_retries + 1):
|
|
798
|
+
try:
|
|
799
|
+
response = await self._client.request(
|
|
800
|
+
method,
|
|
801
|
+
url,
|
|
802
|
+
json=json,
|
|
803
|
+
params=params,
|
|
804
|
+
headers=headers,
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
if response.is_success:
|
|
808
|
+
return response.json()
|
|
809
|
+
|
|
810
|
+
if response.status_code == 429:
|
|
811
|
+
retry_after = response.headers.get("Retry-After")
|
|
812
|
+
retry_after_ms = int(retry_after) * 1000 if retry_after else None
|
|
813
|
+
raise GlacisRateLimitError("Rate limited", retry_after_ms)
|
|
814
|
+
|
|
815
|
+
if 400 <= response.status_code < 500:
|
|
816
|
+
try:
|
|
817
|
+
body = response.json()
|
|
818
|
+
except Exception:
|
|
819
|
+
body = {}
|
|
820
|
+
raise GlacisApiError(
|
|
821
|
+
body.get("error", f"Request failed with status {response.status_code}"),
|
|
822
|
+
response.status_code,
|
|
823
|
+
body.get("code"),
|
|
824
|
+
body,
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
last_error = GlacisApiError(
|
|
828
|
+
f"Request failed with status {response.status_code}",
|
|
829
|
+
response.status_code,
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
833
|
+
last_error = e
|
|
834
|
+
|
|
835
|
+
if attempt < self.max_retries:
|
|
836
|
+
delay = min(self.base_delay * (2**attempt), self.max_delay)
|
|
837
|
+
jitter = random.random() * 0.3 * delay
|
|
838
|
+
await asyncio.sleep(delay + jitter)
|
|
839
|
+
|
|
840
|
+
if last_error:
|
|
841
|
+
raise last_error
|
|
842
|
+
raise GlacisApiError("Request failed", 500)
|
|
843
|
+
|
|
844
|
+
def _debug(self, message: str) -> None:
|
|
845
|
+
"""Log a debug message."""
|
|
846
|
+
if self.debug:
|
|
847
|
+
logger.debug(f"[glacis] {message}")
|