turingpulse-sdk 1.0.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.
@@ -0,0 +1,44 @@
1
+ """TuringPulse SDK public interface."""
2
+
3
+ from .config import TuringPulseConfig, GovernanceDefaults, FingerprintConfig
4
+ from .context import current_context
5
+ from .decorators import instrument
6
+ from .deploy import DeployInfo, register_deploy
7
+ from .exceptions import ConfigurationError, GovernanceBlockedError
8
+ from .governance import GovernanceDirective
9
+ from .kpi import KPIConfig, KPIResult
10
+ from .attachments import AttachmentManager
11
+ from .plugin import TuringPulsePlugin, get_plugin, init
12
+ from .client import PolicyCheckResult
13
+
14
+ __all__ = [
15
+ # Config
16
+ "TuringPulseConfig",
17
+ "GovernanceDefaults",
18
+ "FingerprintConfig",
19
+ # Plugin
20
+ "TuringPulsePlugin",
21
+ "init",
22
+ "get_plugin",
23
+ # Decorators
24
+ "instrument",
25
+ # Governance
26
+ "GovernanceDirective",
27
+ "GovernanceBlockedError",
28
+ # Exceptions
29
+ "ConfigurationError",
30
+ # KPIs
31
+ "KPIConfig",
32
+ "KPIResult",
33
+ # Deploy Tracking
34
+ "register_deploy",
35
+ "DeployInfo",
36
+ # Context
37
+ "current_context",
38
+ # Policy Check
39
+ "PolicyCheckResult",
40
+ # Attachments
41
+ "AttachmentManager",
42
+ # Framework integrations (lazy-loaded)
43
+ "integrations",
44
+ ]
@@ -0,0 +1,176 @@
1
+ """Document attachment support for the TuringPulse Python SDK.
2
+
3
+ Provides ``attach_document``, ``attach_bytes``, and ``attach_text_file``
4
+ which upload files to the ingestion service and inject attachment metadata
5
+ into the current span's event payload.
6
+
7
+ Design principle: NEVER crash customer code. All upload failures are logged
8
+ and silently ignored — the span completes normally without the attachment.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
16
+
17
+ if TYPE_CHECKING:
18
+ from .client import TuringPulseHttpClient
19
+
20
+ logger = logging.getLogger("turingpulse.sdk.attachments")
21
+
22
+ _CONTROL_CHAR_RE = None
23
+
24
+ _global_pending: List[Dict[str, Any]] = []
25
+
26
+
27
+ def _validate_file_path(file_path: str) -> str:
28
+ """Resolve and validate a file path to prevent path-traversal attacks.
29
+
30
+ Rejects paths that resolve outside the original directory intent
31
+ (e.g. ``../../etc/passwd``) and non-regular files.
32
+ """
33
+ resolved = os.path.realpath(os.path.normpath(file_path))
34
+ if not os.path.isfile(resolved):
35
+ raise ValueError(f"Not a regular file: {file_path}")
36
+ return resolved
37
+
38
+
39
+ def _sanitize_filename(filename: str) -> str:
40
+ """Strip path components and control characters from a filename."""
41
+ import re
42
+ global _CONTROL_CHAR_RE
43
+ if _CONTROL_CHAR_RE is None:
44
+ _CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f]")
45
+ name = os.path.basename(filename)
46
+ name = _CONTROL_CHAR_RE.sub("", name)
47
+ return name[:255] if name else "attachment"
48
+
49
+
50
+ def drain_global_pending() -> List[Dict[str, Any]]:
51
+ """Return and clear module-level pending attachments."""
52
+ items = list(_global_pending)
53
+ _global_pending.clear()
54
+ return items
55
+
56
+
57
+ class AttachmentManager:
58
+ """Manages document uploads and metadata injection.
59
+
60
+ Uploaded attachments are automatically injected into the current
61
+ ``ExecutionContext`` when one is active. Attachments uploaded outside
62
+ a span are stored in ``pending_attachments`` so the next root span
63
+ can pick them up.
64
+ """
65
+
66
+ def __init__(self, http_client: "TuringPulseHttpClient", enabled: bool = True) -> None:
67
+ self._http = http_client
68
+ self._enabled = enabled
69
+ self.pending_attachments: List[Dict[str, Any]] = []
70
+
71
+ def attach_document(
72
+ self,
73
+ file_path: str,
74
+ direction: str = "input",
75
+ mime_type: Optional[str] = None,
76
+ ) -> Optional[Dict[str, Any]]:
77
+ """Upload a document from a file path.
78
+
79
+ Returns attachment metadata dict on success, None on failure.
80
+ """
81
+ if not self._enabled:
82
+ return None
83
+ try:
84
+ validated_path = _validate_file_path(file_path)
85
+ filename = _sanitize_filename(validated_path)
86
+ with open(validated_path, "rb") as f:
87
+ data = f.read()
88
+ return self._upload(data, filename, direction, mime_type)
89
+ except Exception:
90
+ logger.warning("Failed to attach document: %s", type(file_path).__name__)
91
+ return None
92
+
93
+ def attach_bytes(
94
+ self,
95
+ data: bytes,
96
+ filename: str,
97
+ direction: str = "input",
98
+ mime_type: Optional[str] = None,
99
+ ) -> Optional[Dict[str, Any]]:
100
+ """Upload raw bytes as an attachment."""
101
+ if not self._enabled:
102
+ return None
103
+ try:
104
+ return self._upload(data, _sanitize_filename(filename), direction, mime_type)
105
+ except Exception as exc:
106
+ logger.warning("Failed to attach bytes: %s", type(exc).__name__)
107
+ return None
108
+
109
+ def attach_text_file(
110
+ self,
111
+ text: str,
112
+ filename: str = "document.txt",
113
+ direction: str = "input",
114
+ ) -> Optional[Dict[str, Any]]:
115
+ """Upload a string as a text attachment."""
116
+ return self.attach_bytes(
117
+ text.encode("utf-8"),
118
+ filename,
119
+ direction,
120
+ mime_type="text/plain",
121
+ )
122
+
123
+ def _upload(
124
+ self,
125
+ data: bytes,
126
+ filename: str,
127
+ direction: str,
128
+ mime_type: Optional[str],
129
+ ) -> Optional[Dict[str, Any]]:
130
+ """Core upload via the HTTP client's multipart upload.
131
+
132
+ If an ``ExecutionContext`` is active the metadata is injected into
133
+ ``ctx.attachments`` automatically. Otherwise it is stored in
134
+ ``pending_attachments`` for later injection by the plugin.
135
+ """
136
+ try:
137
+ result = self._http.upload_attachment(
138
+ data=data,
139
+ filename=filename,
140
+ direction=direction,
141
+ mime_type=mime_type,
142
+ )
143
+ if result:
144
+ meta = {
145
+ "attachment_id": result.get("attachment_id"),
146
+ "filename": result.get("filename"),
147
+ "mime_type": result.get("mime_type"),
148
+ "size_bytes": result.get("size_bytes"),
149
+ "storage_path": result.get("storage_path"),
150
+ "direction": direction,
151
+ "deduplicated": result.get("deduplicated", False),
152
+ }
153
+ self._inject_into_context(meta)
154
+ return meta
155
+ except Exception as exc:
156
+ logger.warning("Attachment upload failed: %s", type(exc).__name__)
157
+ return None
158
+
159
+ def _inject_into_context(self, meta: Dict[str, Any]) -> None:
160
+ """Push attachment metadata into the active span or the global pending list."""
161
+ try:
162
+ from .context import current_context
163
+ ctx = current_context()
164
+ if ctx is not None:
165
+ ctx.attachments.append(meta)
166
+ return
167
+ except Exception:
168
+ pass
169
+ self.pending_attachments.append(meta)
170
+ _global_pending.append(meta)
171
+
172
+ def drain_pending(self) -> List[Dict[str, Any]]:
173
+ """Return and clear any attachments uploaded outside a span."""
174
+ items = list(self.pending_attachments)
175
+ self.pending_attachments.clear()
176
+ return items
@@ -0,0 +1,435 @@
1
+ """HTTP client utilities for TuringPulse services.
2
+
3
+ All outbound HTTP from the SDK flows through ``TuringPulseHttpClient``.
4
+ Authentication is done exclusively via the ``X-API-Key`` header — the
5
+ backend resolves tenant_id and project_id from the key.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import time
12
+ from typing import Any, Dict, Optional, Sequence
13
+
14
+ import re
15
+
16
+ import httpx
17
+ from turingpulse_interfaces import AgentEvent
18
+
19
+ from .config import TuringPulseConfig
20
+
21
+ logger = logging.getLogger("turingpulse.sdk.client")
22
+
23
+ SDK_LANGUAGE = "python"
24
+ MAX_PAYLOAD_BYTES = 5 * 1024 * 1024 # 5 MB
25
+ _SAFE_PATH_SEGMENT = re.compile(r"^[a-zA-Z0-9_\-]+$")
26
+
27
+
28
+ def _sdk_version() -> str:
29
+ try:
30
+ from importlib.metadata import version
31
+ return version("turingpulse-sdk")
32
+ except Exception:
33
+ return "0.1.0"
34
+
35
+
36
+ SDK_VERSION = _sdk_version()
37
+
38
+
39
+ def _sanitize_headers(headers: Dict[str, str]) -> Dict[str, str]:
40
+ """Return a copy of headers with sensitive values redacted for safe logging."""
41
+ sensitive = {"x-api-key", "authorization"}
42
+ return {k: ("***" if k.lower() in sensitive else v) for k, v in headers.items()}
43
+
44
+
45
+ class TuringPulseHttpClient:
46
+ """Centralised HTTP client for all SDK -> backend communication."""
47
+
48
+ def __init__(self, config: TuringPulseConfig):
49
+ self._config = config
50
+ self._base_url = config.endpoint.rstrip("/")
51
+ limits = httpx.Limits(max_keepalive_connections=10, max_connections=20, keepalive_expiry=30)
52
+ self._sync = httpx.Client(timeout=config.timeout_seconds, limits=limits)
53
+ self._async = httpx.AsyncClient(timeout=config.timeout_seconds, limits=limits)
54
+
55
+ def close(self) -> None:
56
+ """Close both sync and async HTTP clients."""
57
+ self._sync.close()
58
+ try:
59
+ import asyncio
60
+ try:
61
+ loop = asyncio.get_running_loop()
62
+ loop.create_task(self._async.aclose())
63
+ except RuntimeError:
64
+ asyncio.run(self._async.aclose())
65
+ except Exception as exc:
66
+ logger.debug("Failed to close async HTTP client: %s", type(exc).__name__)
67
+
68
+ async def aclose(self) -> None:
69
+ """Close both async and sync HTTP clients."""
70
+ self._sync.close()
71
+ await self._async.aclose()
72
+
73
+ # ------------------------------------------------------------------
74
+ # Event emission
75
+ # ------------------------------------------------------------------
76
+
77
+ def emit_events(self, events: Sequence[AgentEvent]) -> None:
78
+ if not events:
79
+ return
80
+ self.emit_serialized_payload(self.serialize_events(events))
81
+
82
+ async def emit_events_async(self, events: Sequence[AgentEvent]) -> None:
83
+ if not events:
84
+ return
85
+ await self.emit_serialized_payload_async(self.serialize_events(events))
86
+
87
+ def serialize_events(self, events: Sequence[AgentEvent]) -> Dict[str, Any]:
88
+ return {"events": [event.model_dump(mode="json") for event in events]}
89
+
90
+ def emit_serialized_payload(self, payload: Dict[str, Any]) -> None:
91
+ payload = self._enforce_payload_limit(payload)
92
+ result = self._post(f"{self._base_url}/api/v1/sdk/events", payload)
93
+ self._check_batch_result(result, payload)
94
+
95
+ async def emit_serialized_payload_async(self, payload: Dict[str, Any]) -> None:
96
+ payload = self._enforce_payload_limit(payload)
97
+ result = await self._apost(f"{self._base_url}/api/v1/sdk/events", payload)
98
+ self._check_batch_result(result, payload)
99
+
100
+ def _check_batch_result(self, result: Any, payload: Dict[str, Any]) -> None:
101
+ """Warn if the backend accepted the HTTP request but failed to process events."""
102
+ if not isinstance(result, dict):
103
+ return
104
+ if result.get("success") is False:
105
+ failed = result.get("failed", 0)
106
+ total = result.get("total", len(payload.get("events", [])))
107
+ logger.warning(
108
+ "Backend accepted request but failed to process %d/%d events "
109
+ "(Kafka/pipeline may be down)",
110
+ failed,
111
+ total,
112
+ )
113
+
114
+ @staticmethod
115
+ def _enforce_payload_limit(payload: Dict[str, Any]) -> Dict[str, Any]:
116
+ """Drop events from the tail until the payload fits within MAX_PAYLOAD_BYTES.
117
+
118
+ Returns a new dict if truncation is needed — never mutates the input.
119
+ """
120
+ import json as _json
121
+ serialized = _json.dumps(payload)
122
+ if len(serialized) <= MAX_PAYLOAD_BYTES:
123
+ return payload
124
+ events = payload.get("events", [])
125
+ if not events:
126
+ logger.warning("Non-event payload exceeds %d bytes, sending truncated", MAX_PAYLOAD_BYTES)
127
+ return payload
128
+ overhead = len(serialized) - len(_json.dumps(events))
129
+ avg_per_event = max(1, (len(serialized) - overhead) // len(events))
130
+ target_count = max(1, (MAX_PAYLOAD_BYTES - overhead) // avg_per_event)
131
+ if target_count < len(events):
132
+ truncated = events[:target_count]
133
+ logger.warning("Payload exceeded %d bytes, truncated to %d events", MAX_PAYLOAD_BYTES, len(truncated))
134
+ return {**payload, "events": truncated}
135
+ logger.warning("Payload exceeded %d bytes, truncated to %d events", MAX_PAYLOAD_BYTES, len(events))
136
+ return payload
137
+
138
+ # ------------------------------------------------------------------
139
+ # Task / HITL helpers
140
+ # ------------------------------------------------------------------
141
+
142
+ def create_task(self, payload: Dict[str, Any]) -> Optional[str]:
143
+ url = f"{self._base_url}/api/v1/tasks"
144
+ response = self._post(url, payload)
145
+ if isinstance(response, dict):
146
+ return response.get("task_id") or payload.get("task_id")
147
+ return payload.get("task_id")
148
+
149
+ def add_hitl_action(self, task_id: str, payload: Dict[str, Any]) -> None:
150
+ if not _SAFE_PATH_SEGMENT.match(task_id):
151
+ logger.warning("Invalid task_id rejected (possible path traversal): %s", type(task_id).__name__)
152
+ return
153
+ self._post(f"{self._base_url}/api/v1/tasks/{task_id}/hitl", payload)
154
+
155
+ # ------------------------------------------------------------------
156
+ # Fingerprint / deploy / custom-change helpers
157
+ # ------------------------------------------------------------------
158
+
159
+ def post_fingerprint(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
160
+ return self._post(f"{self._base_url}/api/v1/fingerprints", payload)
161
+
162
+ def post_deploy(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
163
+ return self._post(f"{self._base_url}/api/v1/deploys", payload)
164
+
165
+ def post_custom_change(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
166
+ return self._post(f"{self._base_url}/api/v1/changes", payload)
167
+
168
+ # ------------------------------------------------------------------
169
+ # Headers
170
+ # ------------------------------------------------------------------
171
+
172
+ def _headers(self) -> Dict[str, str]:
173
+ headers: Dict[str, str] = {
174
+ "Content-Type": "application/json",
175
+ "X-SDK-Version": SDK_VERSION,
176
+ "X-SDK-Language": SDK_LANGUAGE,
177
+ }
178
+ if self._config.api_key:
179
+ headers["X-API-Key"] = self._config.api_key
180
+ return headers
181
+
182
+ # ------------------------------------------------------------------
183
+ # Core POST with retries — shared response handling
184
+ # ------------------------------------------------------------------
185
+
186
+ @staticmethod
187
+ def _handle_response(response: httpx.Response, url: str) -> Any:
188
+ """Process an httpx response — shared by sync and async paths."""
189
+ if response.status_code == 402:
190
+ TuringPulseHttpClient._log_quota_exceeded(response)
191
+ response.raise_for_status()
192
+ if response.status_code == 403:
193
+ logger.warning("TuringPulse 403 Forbidden: %s", url)
194
+ response.raise_for_status()
195
+ if not response.content:
196
+ return None
197
+ content_type = response.headers.get("content-type", "")
198
+ if "application/json" in content_type:
199
+ return response.json()
200
+ return None
201
+
202
+ @staticmethod
203
+ def _is_terminal_status(status_code: int) -> bool:
204
+ return status_code in (401, 402, 403, 404)
205
+
206
+ def _backoff(self, attempt: int, retry_after: float | None = None) -> float:
207
+ default = min(self._config.retry_backoff_base * (attempt + 1), self._config.retry_backoff_max)
208
+ if retry_after is not None and retry_after > 0:
209
+ return min(retry_after, self._config.retry_backoff_max)
210
+ return default
211
+
212
+ @staticmethod
213
+ def _parse_retry_after(response: httpx.Response) -> float | None:
214
+ header = response.headers.get("retry-after")
215
+ if header is None:
216
+ return None
217
+ try:
218
+ return float(header)
219
+ except (ValueError, TypeError):
220
+ return None
221
+
222
+ def _post(self, url: str, json_payload: Dict[str, Any]) -> Any:
223
+ last_exc: Exception | None = None
224
+ headers = self._headers()
225
+ for attempt in range(self._config.max_retries + 1):
226
+ try:
227
+ response = self._sync.post(url, json=json_payload, headers=headers)
228
+ return self._handle_response(response, url)
229
+ except httpx.HTTPStatusError as exc:
230
+ if self._is_terminal_status(exc.response.status_code):
231
+ raise
232
+ last_exc = exc
233
+ retry_after = self._parse_retry_after(exc.response)
234
+ time.sleep(self._backoff(attempt, retry_after))
235
+ except httpx.HTTPError as exc:
236
+ last_exc = exc
237
+ time.sleep(self._backoff(attempt))
238
+ if last_exc:
239
+ logger.warning("TuringPulse request failed after retries: %s", type(last_exc).__name__)
240
+ raise last_exc
241
+ return None
242
+
243
+ async def _apost(self, url: str, json_payload: Dict[str, Any]) -> Any:
244
+ last_exc: Exception | None = None
245
+ headers = self._headers()
246
+ for attempt in range(self._config.max_retries + 1):
247
+ try:
248
+ response = await self._async.post(url, json=json_payload, headers=headers)
249
+ return self._handle_response(response, url)
250
+ except httpx.HTTPStatusError as exc:
251
+ if self._is_terminal_status(exc.response.status_code):
252
+ raise
253
+ last_exc = exc
254
+ retry_after = self._parse_retry_after(exc.response)
255
+ await _async_sleep(self._backoff(attempt, retry_after))
256
+ except httpx.HTTPError as exc:
257
+ last_exc = exc
258
+ await _async_sleep(self._backoff(attempt))
259
+ if last_exc:
260
+ logger.warning("TuringPulse async request failed after retries: %s", type(last_exc).__name__)
261
+ raise last_exc
262
+ return None
263
+
264
+ # ------------------------------------------------------------------
265
+ # Policy check — same pattern, unified payload builder
266
+ # ------------------------------------------------------------------
267
+
268
+ def _build_policy_payload(self, **kwargs: Any) -> Dict[str, Any]:
269
+ """Build and clean the policy check payload, auto-populating context fields.
270
+
271
+ Applies size caps to text fields to prevent oversized payloads
272
+ and strips None values.
273
+ """
274
+ span_id = kwargs.get("span_id")
275
+ node_type = kwargs.get("node_type")
276
+ if span_id is None or node_type is None:
277
+ try:
278
+ from .context import current_context
279
+ ctx = current_context()
280
+ if ctx:
281
+ span_id = span_id or getattr(ctx, "span_id", None)
282
+ node_type = node_type or getattr(ctx, "node_type", None)
283
+ except Exception:
284
+ pass
285
+ kwargs["span_id"] = span_id
286
+ kwargs["node_type"] = node_type
287
+
288
+ text_cap = 10_000
289
+ for key in ("output_text", "input_text"):
290
+ val = kwargs.get(key)
291
+ if isinstance(val, str) and len(val) > text_cap:
292
+ kwargs[key] = val[:text_cap]
293
+
294
+ return {k: v for k, v in kwargs.items() if v is not None}
295
+
296
+ def _policy_check_url_and_timeout(self):
297
+ url = f"{self._base_url}/api/v1/hitl/policy/check"
298
+ total = self._config.policy_check_timeout
299
+ timeout = httpx.Timeout(total, connect=min(0.5, total / 2))
300
+ return url, self._headers(), timeout
301
+
302
+ @staticmethod
303
+ def _parse_policy_response(data: Dict[str, Any]) -> "PolicyCheckResult":
304
+ return PolicyCheckResult(
305
+ action=data.get("action", "allow"),
306
+ triggered=data.get("triggered", False),
307
+ reason=data.get("reason"),
308
+ policy_ids=data.get("policy_ids", []),
309
+ )
310
+
311
+ @staticmethod
312
+ def _policy_check_fallback(e: Exception) -> "PolicyCheckResult":
313
+ safe_msg = type(e).__name__
314
+ logger.warning("Policy check failed (fail-open): %s", safe_msg)
315
+ return PolicyCheckResult(action="flag", triggered=True, reason=f"check_error: {safe_msg}")
316
+
317
+ def policy_check(self, **kwargs: Any) -> "PolicyCheckResult":
318
+ """Synchronous policy check. Fail-open on infrastructure failures."""
319
+ payload = self._build_policy_payload(**kwargs)
320
+ url, headers, timeout = self._policy_check_url_and_timeout()
321
+ try:
322
+ response = self._sync.post(url, json=payload, headers=headers, timeout=timeout)
323
+ response.raise_for_status()
324
+ return self._parse_policy_response(response.json())
325
+ except Exception as e:
326
+ return self._policy_check_fallback(e)
327
+
328
+ async def policy_check_async(self, **kwargs: Any) -> "PolicyCheckResult":
329
+ """Async policy check. Fail-open on infrastructure failures."""
330
+ payload = self._build_policy_payload(**kwargs)
331
+ url, headers, timeout = self._policy_check_url_and_timeout()
332
+ try:
333
+ response = await self._async.post(url, json=payload, headers=headers, timeout=timeout)
334
+ response.raise_for_status()
335
+ return self._parse_policy_response(response.json())
336
+ except Exception as e:
337
+ return self._policy_check_fallback(e)
338
+
339
+ # ------------------------------------------------------------------
340
+ # Attachment upload (multipart/form-data)
341
+ # ------------------------------------------------------------------
342
+
343
+ def _upload_headers(self) -> Dict[str, str]:
344
+ """Headers for multipart uploads (no Content-Type — httpx sets it)."""
345
+ headers: Dict[str, str] = {
346
+ "X-SDK-Version": SDK_VERSION,
347
+ "X-SDK-Language": SDK_LANGUAGE,
348
+ }
349
+ if self._config.api_key:
350
+ headers["X-API-Key"] = self._config.api_key
351
+ return headers
352
+
353
+ def upload_attachment(
354
+ self,
355
+ data: bytes,
356
+ filename: str,
357
+ direction: str = "input",
358
+ mime_type: Optional[str] = None,
359
+ ) -> Optional[Dict[str, Any]]:
360
+ """Upload a document attachment via multipart/form-data."""
361
+ url = f"{self._base_url}/api/v1/attachments"
362
+ effective_mime = mime_type or "application/octet-stream"
363
+ files = {"file": (filename, data, effective_mime)}
364
+ form_data = {"direction": direction}
365
+ try:
366
+ response = self._sync.post(url, headers=self._upload_headers(), files=files, data=form_data)
367
+ response.raise_for_status()
368
+ content_type = response.headers.get("content-type", "")
369
+ if "application/json" in content_type:
370
+ return response.json()
371
+ return None
372
+ except Exception as exc:
373
+ logger.warning("Attachment upload failed: %s", type(exc).__name__)
374
+ return None
375
+
376
+ # ------------------------------------------------------------------
377
+ # Helpers
378
+ # ------------------------------------------------------------------
379
+
380
+ @staticmethod
381
+ def _log_quota_exceeded(response: httpx.Response) -> None:
382
+ try:
383
+ error_data = response.json()
384
+ detail = error_data.get("detail", {})
385
+ if isinstance(detail, dict):
386
+ logger.warning(
387
+ "TuringPulse quota exceeded: resource=%s, current=%d, limit=%d",
388
+ detail.get("resource", "unknown"),
389
+ detail.get("current", 0),
390
+ detail.get("limit", 0),
391
+ )
392
+ except (ValueError, KeyError):
393
+ logger.warning("TuringPulse quota exceeded (HTTP %d)", response.status_code)
394
+
395
+
396
+ class PolicyCheckResult:
397
+ """Result of a policy check."""
398
+
399
+ __slots__ = ("action", "triggered", "reason", "policy_ids")
400
+
401
+ def __init__(
402
+ self,
403
+ action: str = "allow",
404
+ triggered: bool = False,
405
+ reason: Optional[str] = None,
406
+ policy_ids: Optional[list] = None,
407
+ ):
408
+ self.action = action
409
+ self.triggered = triggered
410
+ self.reason = reason
411
+ self.policy_ids = policy_ids or []
412
+
413
+ @property
414
+ def allowed(self) -> bool:
415
+ return self.action == "allow"
416
+
417
+ @property
418
+ def blocked(self) -> bool:
419
+ return self.action == "block"
420
+
421
+ @property
422
+ def flagged(self) -> bool:
423
+ return self.action == "flag"
424
+
425
+ def __repr__(self) -> str:
426
+ return f"PolicyCheckResult(action={self.action!r}, triggered={self.triggered})"
427
+
428
+
429
+ async def _async_sleep(delay: float) -> None:
430
+ try:
431
+ import anyio
432
+ await anyio.sleep(delay)
433
+ except ModuleNotFoundError:
434
+ import asyncio
435
+ await asyncio.sleep(delay)