glacis 0.1.4__py3-none-any.whl → 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.
@@ -1,53 +1,44 @@
1
1
  """
2
2
  GLACIS integration for OpenAI.
3
3
 
4
- Provides an attested OpenAI client wrapper that automatically logs all
5
- completions to the GLACIS transparency log. Supports both online (server-witnessed)
6
- and offline (locally-signed) modes.
7
-
8
- Example (online):
9
- >>> from glacis.integrations.openai import attested_openai
10
- >>> client = attested_openai(glacis_api_key="glsk_live_xxx", openai_api_key="sk-xxx")
11
- >>> response = client.chat.completions.create(
12
- ... model="gpt-4",
13
- ... messages=[{"role": "user", "content": "Hello!"}]
4
+ Provides an attested OpenAI client wrapper that automatically:
5
+ 1. Runs enabled controls (PII/PHI redaction, jailbreak detection, etc.)
6
+ 2. Logs all completions to the GLACIS transparency log
7
+ 3. Creates control plane attestations
8
+
9
+ Example:
10
+ >>> from glacis.integrations.openai import attested_openai, get_last_receipt
11
+ >>> client = attested_openai(
12
+ ... openai_api_key="sk-xxx",
13
+ ... offline=True,
14
+ ... signing_seed=os.urandom(32),
14
15
  ... )
15
- # Response is automatically attested to GLACIS
16
-
17
- Example (offline):
18
- >>> client = attested_openai(openai_api_key="sk-xxx", offline=True, signing_seed=seed)
19
16
  >>> response = client.chat.completions.create(
20
17
  ... model="gpt-4o",
21
- ... messages=[{"role": "user", "content": "Hello!"}],
18
+ ... messages=[{"role": "user", "content": "Hello!"}]
22
19
  ... )
23
20
  >>> receipt = get_last_receipt()
24
21
  """
25
22
 
26
23
  from __future__ import annotations
27
24
 
28
- import threading
29
- from typing import TYPE_CHECKING, Any, Optional, Union
25
+ from typing import TYPE_CHECKING, Any, Literal, Optional, Union
26
+
27
+ from glacis.integrations.base import (
28
+ GlacisBlockedError,
29
+ create_controls_runner,
30
+ create_glacis_client,
31
+ get_evidence,
32
+ get_last_receipt,
33
+ initialize_config,
34
+ set_last_receipt,
35
+ store_evidence,
36
+ suppress_noisy_loggers,
37
+ )
30
38
 
31
39
  if TYPE_CHECKING:
32
40
  from openai import OpenAI
33
41
 
34
- from glacis.models import AttestReceipt, OfflineAttestReceipt
35
-
36
-
37
- # Thread-local storage for the last receipt
38
- _thread_local = threading.local()
39
-
40
-
41
- def get_last_receipt() -> Optional[Union["AttestReceipt", "OfflineAttestReceipt"]]:
42
- """
43
- Get the last attestation receipt from the current thread.
44
-
45
- Returns:
46
- The last AttestReceipt or OfflineAttestReceipt, or None if no attestation
47
- has been made in this thread.
48
- """
49
- return getattr(_thread_local, "last_receipt", None)
50
-
51
42
 
52
43
  def attested_openai(
53
44
  glacis_api_key: Optional[str] = None,
@@ -55,15 +46,14 @@ def attested_openai(
55
46
  glacis_base_url: str = "https://api.glacis.io",
56
47
  service_id: str = "openai",
57
48
  debug: bool = False,
58
- offline: bool = False,
49
+ offline: Optional[bool] = None,
59
50
  signing_seed: Optional[bytes] = None,
51
+ redaction: Union[bool, Literal["fast", "full"], None] = None,
52
+ config: Optional[str] = None,
60
53
  **openai_kwargs: Any,
61
54
  ) -> "OpenAI":
62
55
  """
63
- Create an attested OpenAI client.
64
-
65
- All chat completions are automatically attested. Supports both online and offline modes.
66
- Note: Streaming is not currently supported.
56
+ Create an attested OpenAI client with controls (PII redaction, jailbreak detection).
67
57
 
68
58
  Args:
69
59
  glacis_api_key: GLACIS API key (required for online mode)
@@ -73,36 +63,19 @@ def attested_openai(
73
63
  debug: Enable debug logging
74
64
  offline: Enable offline mode (local signing, no server)
75
65
  signing_seed: 32-byte Ed25519 signing seed (required for offline mode)
66
+ redaction: PII/PHI redaction mode - "fast", "full", True, False, or None
67
+ config: Path to glacis.yaml config file
76
68
  **openai_kwargs: Additional arguments passed to OpenAI client
77
69
 
78
70
  Returns:
79
71
  Wrapped OpenAI client
80
72
 
81
- Example (online):
82
- >>> client = attested_openai(
83
- ... glacis_api_key="glsk_live_xxx",
84
- ... openai_api_key="sk-xxx"
85
- ... )
86
- >>> response = client.chat.completions.create(
87
- ... model="gpt-4",
88
- ... messages=[{"role": "user", "content": "Hello!"}]
89
- ... )
90
-
91
- Example (offline):
92
- >>> import os
93
- >>> seed = os.urandom(32)
94
- >>> client = attested_openai(
95
- ... openai_api_key="sk-xxx",
96
- ... offline=True,
97
- ... signing_seed=seed,
98
- ... )
99
- >>> response = client.chat.completions.create(
100
- ... model="gpt-4o",
101
- ... messages=[{"role": "user", "content": "Hello!"}],
102
- ... )
103
- >>> receipt = get_last_receipt()
104
- >>> assert receipt.witness_status == "UNVERIFIED"
73
+ Raises:
74
+ GlacisBlockedError: If a control blocks the request
105
75
  """
76
+ # Suppress noisy loggers
77
+ suppress_noisy_loggers(["openai", "openai._base_client"])
78
+
106
79
  try:
107
80
  from openai import OpenAI
108
81
  except ImportError:
@@ -111,25 +84,26 @@ def attested_openai(
111
84
  "Install it with: pip install glacis[openai]"
112
85
  )
113
86
 
114
- from glacis import Glacis
115
87
 
116
- # Create Glacis client (online or offline)
117
- if offline:
118
- if not signing_seed:
119
- raise ValueError("signing_seed is required for offline mode")
120
- glacis = Glacis(
121
- mode="offline",
122
- signing_seed=signing_seed,
123
- debug=debug,
124
- )
125
- else:
126
- if not glacis_api_key:
127
- raise ValueError("glacis_api_key is required for online mode")
128
- glacis = Glacis(
129
- api_key=glacis_api_key,
130
- base_url=glacis_base_url,
131
- debug=debug,
132
- )
88
+ # Initialize config and determine modes
89
+ cfg, effective_offline, effective_service_id = initialize_config(
90
+ config_path=config,
91
+ redaction=redaction,
92
+ offline=offline,
93
+ glacis_api_key=glacis_api_key,
94
+ default_service_id="openai",
95
+ service_id=service_id,
96
+ )
97
+
98
+ # Create controls runner and Glacis client
99
+ controls_runner = create_controls_runner(cfg, debug)
100
+ glacis = create_glacis_client(
101
+ offline=effective_offline,
102
+ signing_seed=signing_seed,
103
+ glacis_api_key=glacis_api_key,
104
+ glacis_base_url=glacis_base_url,
105
+ debug=debug,
106
+ )
133
107
 
134
108
  # Create the OpenAI client
135
109
  client_kwargs: dict[str, Any] = {**openai_kwargs}
@@ -142,67 +116,128 @@ def attested_openai(
142
116
  original_create = client.chat.completions.create
143
117
 
144
118
  def attested_create(*args: Any, **kwargs: Any) -> Any:
145
- # Check for streaming - not supported
146
119
  if kwargs.get("stream", False):
147
120
  raise NotImplementedError(
148
121
  "Streaming is not currently supported with attested_openai. "
149
122
  "Use stream=False for now."
150
123
  )
151
124
 
152
- # Extract input
153
125
  messages = kwargs.get("messages", [])
154
126
  model = kwargs.get("model", "unknown")
155
127
 
156
- # Make the API call
128
+ # Run controls if enabled
129
+ if controls_runner:
130
+ from glacis.integrations.base import (
131
+ ControlResultsAccumulator,
132
+ create_control_plane_attestation_from_accumulator,
133
+ handle_blocked_request,
134
+ process_text_for_controls,
135
+ )
136
+
137
+ accumulator = ControlResultsAccumulator()
138
+ processed_messages = []
139
+
140
+ # Find the last user message index (the new message to check)
141
+ last_user_idx = -1
142
+ for i, msg in enumerate(messages):
143
+ if isinstance(msg, dict) and msg.get("role") == "user":
144
+ last_user_idx = i
145
+
146
+ for i, msg in enumerate(messages):
147
+ role = msg.get("role", "") if isinstance(msg, dict) else ""
148
+ # Only run controls on the LAST user message (the new one)
149
+ if (
150
+ isinstance(msg, dict)
151
+ and isinstance(msg.get("content"), str)
152
+ and role == "user"
153
+ and i == last_user_idx
154
+ ):
155
+ content = msg["content"]
156
+ final_text = process_text_for_controls(controls_runner, content, accumulator)
157
+ processed_messages.append({**msg, "content": final_text})
158
+ else:
159
+ processed_messages.append(msg)
160
+
161
+ kwargs["messages"] = processed_messages
162
+ messages = processed_messages
163
+
164
+ # Build control plane attestation
165
+ control_plane_results = create_control_plane_attestation_from_accumulator(
166
+ accumulator, cfg, model, "openai", "chat.completions"
167
+ )
168
+
169
+ # Check if we need to block BEFORE making the API call
170
+ if accumulator.should_block:
171
+ handle_blocked_request(
172
+ glacis_client=glacis,
173
+ service_id=effective_service_id,
174
+ input_data={"model": model, "messages": messages},
175
+ control_plane_results=control_plane_results,
176
+ provider="openai",
177
+ model=model,
178
+ jailbreak_score=accumulator.jailbreak_summary.score
179
+ if accumulator.jailbreak_summary
180
+ else 0.0,
181
+ debug=debug,
182
+ )
183
+ else:
184
+ control_plane_results = None
185
+
186
+ # Make the API call (only if not blocked)
157
187
  response = original_create(*args, **kwargs)
158
188
 
159
- # Attest the response
189
+ # Build input/output data
190
+ input_data = {"model": model, "messages": messages}
191
+ output_data = {
192
+ "model": response.model,
193
+ "choices": [
194
+ {
195
+ "message": {"role": c.message.role, "content": c.message.content},
196
+ "finish_reason": c.finish_reason,
197
+ }
198
+ for c in response.choices
199
+ ],
200
+ "usage": {
201
+ "prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
202
+ "completion_tokens": response.usage.completion_tokens if response.usage else 0,
203
+ "total_tokens": response.usage.total_tokens if response.usage else 0,
204
+ } if response.usage else None,
205
+ }
206
+
207
+ # Attest and store
160
208
  try:
161
209
  receipt = glacis.attest(
162
- service_id=service_id,
210
+ service_id=effective_service_id,
163
211
  operation_type="completion",
164
- input={
165
- "model": model,
166
- "messages": messages,
167
- },
168
- output={
169
- "model": response.model,
170
- "choices": [
171
- {
172
- "message": {
173
- "role": c.message.role,
174
- "content": c.message.content,
175
- },
176
- "finish_reason": c.finish_reason,
177
- }
178
- for c in response.choices
179
- ],
180
- "usage": {
181
- "prompt_tokens": (
182
- response.usage.prompt_tokens if response.usage else 0
183
- ),
184
- "completion_tokens": (
185
- response.usage.completion_tokens if response.usage else 0
186
- ),
187
- "total_tokens": (
188
- response.usage.total_tokens if response.usage else 0
189
- ),
190
- }
191
- if response.usage
192
- else None,
193
- },
212
+ input=input_data,
213
+ output=output_data,
194
214
  metadata={"provider": "openai", "model": model},
215
+ control_plane_results=control_plane_results,
216
+ )
217
+ set_last_receipt(receipt)
218
+ store_evidence(
219
+ receipt=receipt,
220
+ service_id=effective_service_id,
221
+ operation_type="completion",
222
+ input_data=input_data,
223
+ output_data=output_data,
224
+ control_plane_results=control_plane_results,
225
+ metadata={"provider": "openai", "model": model},
226
+ debug=debug,
195
227
  )
196
- _thread_local.last_receipt = receipt
197
- if debug:
198
- print(f"[glacis] Attestation created: {receipt.attestation_id}")
199
228
  except Exception as e:
200
229
  if debug:
201
230
  print(f"[glacis] Attestation failed: {e}")
202
231
 
203
232
  return response
204
233
 
205
- # Replace the create method
206
234
  client.chat.completions.create = attested_create # type: ignore
207
-
208
235
  return client
236
+
237
+
238
+ __all__ = [
239
+ "attested_openai",
240
+ "get_last_receipt",
241
+ "get_evidence",
242
+ "GlacisBlockedError",
243
+ ]
glacis/models.py CHANGED
@@ -133,6 +133,11 @@ class AttestReceipt(BaseModel):
133
133
  epoch_id: Optional[str] = Field(alias="epochId", default=None)
134
134
  receipt: Optional[FullReceipt] = Field(default=None, description="Full receipt with proofs")
135
135
  verify_url: str = Field(alias="verifyUrl", description="Verification endpoint URL")
136
+ control_plane_results: Optional["ControlPlaneAttestation"] = Field(
137
+ alias="controlPlaneResults",
138
+ default=None,
139
+ description="Control plane results from executed controls",
140
+ )
136
141
 
137
142
  # Computed properties for convenience
138
143
  @property
@@ -182,9 +187,9 @@ class OrgInfo(BaseModel):
182
187
  class Verification(BaseModel):
183
188
  """Verification details."""
184
189
 
185
- signature_valid: bool = Field(alias="signatureValid")
186
- proof_valid: bool = Field(alias="proofValid")
187
- verified_at: str = Field(alias="verifiedAt")
190
+ signature_valid: bool = Field(alias="signatureValid", default=False)
191
+ proof_valid: bool = Field(alias="proofValid", default=False)
192
+ verified_at: Optional[str] = Field(alias="verifiedAt", default=None)
188
193
 
189
194
  class Config:
190
195
  populate_by_name = True
@@ -198,9 +203,11 @@ class VerifyResult(BaseModel):
198
203
  default=None, description="The attestation entry (if valid)"
199
204
  )
200
205
  org: Optional[OrgInfo] = Field(default=None, description="Organization info")
201
- verification: Verification = Field(description="Verification details")
202
- proof: MerkleInclusionProof = Field(description="Merkle proof")
203
- tree_head: SignedTreeHead = Field(alias="treeHead", description="Current tree head")
206
+ verification: Optional[Verification] = Field(default=None, description="Verification details")
207
+ proof: Optional[MerkleInclusionProof] = Field(default=None, description="Merkle proof")
208
+ tree_head: Optional[SignedTreeHead] = Field(
209
+ alias="treeHead", default=None, description="Current tree head"
210
+ )
204
211
  error: Optional[str] = Field(
205
212
  default=None, description="Error message if validation failed"
206
213
  )
@@ -226,16 +233,18 @@ class LogQueryParams(BaseModel):
226
233
  class LogEntry(BaseModel):
227
234
  """Log entry in query results."""
228
235
 
229
- entry_id: str = Field(alias="entryId")
230
- timestamp: str
231
- org_id: str = Field(alias="orgId")
236
+ # Server returns attestationId as the primary identifier
237
+ attestation_id: str = Field(alias="attestationId")
238
+ entry_id: Optional[str] = Field(alias="entryId", default=None)
239
+ timestamp: Optional[str] = None
240
+ org_id: Optional[str] = Field(alias="orgId", default=None)
232
241
  org_name: Optional[str] = Field(alias="orgName", default=None)
233
- service_id: str = Field(alias="serviceId")
234
- operation_type: str = Field(alias="operationType")
235
- payload_hash: str = Field(alias="payloadHash")
236
- signature: str
237
- leaf_index: int = Field(alias="leafIndex")
238
- leaf_hash: str = Field(alias="leafHash")
242
+ service_id: Optional[str] = Field(alias="serviceId", default=None)
243
+ operation_type: Optional[str] = Field(alias="operationType", default=None)
244
+ payload_hash: Optional[str] = Field(alias="payloadHash", default=None)
245
+ signature: Optional[str] = None
246
+ leaf_index: Optional[int] = Field(alias="leafIndex", default=None)
247
+ leaf_hash: Optional[str] = Field(alias="leafHash", default=None)
239
248
 
240
249
  class Config:
241
250
  populate_by_name = True
@@ -250,7 +259,9 @@ class LogQueryResult(BaseModel):
250
259
  alias="nextCursor", default=None, description="Cursor for next page"
251
260
  )
252
261
  count: int = Field(description="Number of entries returned")
253
- tree_head: SignedTreeHead = Field(alias="treeHead", description="Current tree head")
262
+ tree_head: Optional[SignedTreeHead] = Field(
263
+ alias="treeHead", default=None, description="Current tree head"
264
+ )
254
265
 
255
266
  class Config:
256
267
  populate_by_name = True
@@ -292,6 +303,183 @@ class GlacisRateLimitError(GlacisApiError):
292
303
  self.retry_after_ms = retry_after_ms
293
304
 
294
305
 
306
+ # ==============================================================================
307
+ # Control Plane Attestation Models
308
+ # ==============================================================================
309
+
310
+ ControlType = Literal[
311
+ "content_safety",
312
+ "pii",
313
+ "jailbreak",
314
+ "topic",
315
+ "prompt_security",
316
+ "grounding",
317
+ "word_filter",
318
+ "custom",
319
+ ]
320
+
321
+ ControlStatus = Literal["pass", "flag", "block", "error"]
322
+
323
+
324
+ class ModelInfo(BaseModel):
325
+ """Model information for policy context."""
326
+
327
+ model_id: str = Field(alias="modelId")
328
+ provider: str
329
+ system_prompt_hash: Optional[str] = Field(alias="systemPromptHash", default=None)
330
+
331
+ class Config:
332
+ populate_by_name = True
333
+
334
+
335
+ class PolicyScope(BaseModel):
336
+ """Scope for policy application."""
337
+
338
+ tenant_id: str = Field(alias="tenantId")
339
+ endpoint: str
340
+ user_class: Optional[str] = Field(alias="userClass", default=None)
341
+
342
+ class Config:
343
+ populate_by_name = True
344
+
345
+
346
+ class PolicyContext(BaseModel):
347
+ """Policy context for attestation."""
348
+
349
+ id: str
350
+ version: str
351
+ model: Optional[ModelInfo] = None
352
+ scope: PolicyScope
353
+
354
+ class Config:
355
+ populate_by_name = True
356
+
357
+
358
+ class Determination(BaseModel):
359
+ """Final determination for the request."""
360
+
361
+ action: Literal["forwarded", "redacted", "blocked"]
362
+ trigger: Optional[str] = None
363
+ confidence: float = Field(ge=0.0, le=1.0)
364
+
365
+ class Config:
366
+ populate_by_name = True
367
+
368
+
369
+ class ControlExecution(BaseModel):
370
+ """Record of a control execution."""
371
+
372
+ id: str
373
+ type: ControlType
374
+ version: str
375
+ provider: str # "aws", "azure", "glacis", "custom", etc.
376
+ latency_ms: int = Field(alias="latencyMs")
377
+ status: ControlStatus
378
+ result_hash: Optional[str] = Field(alias="resultHash", default=None)
379
+
380
+ class Config:
381
+ populate_by_name = True
382
+
383
+
384
+ class SafetyScores(BaseModel):
385
+ """Aggregated safety scores."""
386
+
387
+ overall_risk: float = Field(alias="overallRisk", ge=0.0, le=1.0)
388
+ scores: dict[str, float] = Field(default_factory=dict)
389
+
390
+ class Config:
391
+ populate_by_name = True
392
+
393
+
394
+ class PiiPhiSummary(BaseModel):
395
+ """Summary of PII/PHI detection and handling.
396
+
397
+ This model captures metadata about PII/PHI detection for attestation.
398
+ The actual redacted text is stored in evidence, not in the attestation schema.
399
+ """
400
+
401
+ detected: bool = False
402
+ action: Literal["none", "redacted", "blocked"] = "none"
403
+ categories: list[str] = Field(default_factory=list)
404
+ count: int = 0
405
+
406
+ class Config:
407
+ populate_by_name = True
408
+
409
+
410
+ class JailbreakSummary(BaseModel):
411
+ """Summary of jailbreak/prompt injection detection for attestation.
412
+
413
+ This model captures metadata about jailbreak detection results.
414
+ The raw model outputs and detailed scores are stored in evidence.
415
+ """
416
+
417
+ detected: bool = False
418
+ score: float = Field(default=0.0, ge=0.0, le=1.0, description="Model confidence score")
419
+ action: Literal["pass", "flag", "block", "log"] = "pass"
420
+ categories: list[str] = Field(
421
+ default_factory=list, description="Detection categories (e.g., ['jailbreak'])"
422
+ )
423
+ backend: str = Field(default="", description="Backend model used for detection")
424
+
425
+ class Config:
426
+ populate_by_name = True
427
+
428
+
429
+ class DeepInspection(BaseModel):
430
+ """Deep inspection results from L2 verification."""
431
+
432
+ judge_ids: list[str] = Field(alias="judgeIds", default_factory=list)
433
+ nonconformity_score: float = Field(alias="nonconformityScore", ge=0.0, le=1.0)
434
+ recommendation: Literal["uphold", "borderline", "escalate"]
435
+ evaluation_rationale: str = Field(alias="evaluationRationale")
436
+
437
+ class Config:
438
+ populate_by_name = True
439
+
440
+
441
+ class SamplingDecision(BaseModel):
442
+ """Sampling decision details."""
443
+
444
+ sampled: bool
445
+ reason: Literal["prf", "policy_trigger", "forced"]
446
+ prf_tag: Optional[str] = Field(alias="prfTag", default=None)
447
+ rate: float = Field(ge=0.0, le=1.0)
448
+
449
+ class Config:
450
+ populate_by_name = True
451
+
452
+
453
+ class SamplingMetadata(BaseModel):
454
+ """Sampling metadata for attestation level."""
455
+
456
+ level: Literal["L0", "L2"]
457
+ decision: SamplingDecision
458
+
459
+ class Config:
460
+ populate_by_name = True
461
+
462
+
463
+ class ControlPlaneAttestation(BaseModel):
464
+ """Control plane attestation capturing policy, controls, and safety metadata."""
465
+
466
+ schema_version: Literal["1.0"] = "1.0"
467
+ policy: PolicyContext
468
+ determination: Determination
469
+ controls: list[ControlExecution] = Field(default_factory=list)
470
+ safety: SafetyScores
471
+ pii_phi: Optional[PiiPhiSummary] = Field(alias="piiPhi", default=None)
472
+ jailbreak: Optional[JailbreakSummary] = Field(
473
+ default=None, description="Jailbreak detection results"
474
+ )
475
+ evidence_commitment: Optional[str] = Field(alias="evidenceCommitment", default=None)
476
+ deep_inspection: Optional[DeepInspection] = Field(alias="deepInspection", default=None)
477
+ sampling: SamplingMetadata
478
+
479
+ class Config:
480
+ populate_by_name = True
481
+
482
+
295
483
  # Offline Mode Models
296
484
 
297
485
 
@@ -324,6 +512,11 @@ class OfflineAttestReceipt(BaseModel):
324
512
  alias="witnessStatus",
325
513
  description="Always UNVERIFIED for offline receipts",
326
514
  )
515
+ control_plane_results: Optional[ControlPlaneAttestation] = Field(
516
+ alias="controlPlaneResults",
517
+ default=None,
518
+ description="Control plane results from executed controls",
519
+ )
327
520
 
328
521
  class Config:
329
522
  populate_by_name = True