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.
- turingpulse_sdk/__init__.py +44 -0
- turingpulse_sdk/attachments.py +176 -0
- turingpulse_sdk/client.py +435 -0
- turingpulse_sdk/config.py +234 -0
- turingpulse_sdk/context.py +220 -0
- turingpulse_sdk/decorators.py +143 -0
- turingpulse_sdk/deploy.py +249 -0
- turingpulse_sdk/event_builder.py +223 -0
- turingpulse_sdk/exceptions.py +38 -0
- turingpulse_sdk/fingerprint.py +388 -0
- turingpulse_sdk/governance.py +147 -0
- turingpulse_sdk/instrumentation.py +86 -0
- turingpulse_sdk/integrations/__init__.py +52 -0
- turingpulse_sdk/integrations/autogen.py +335 -0
- turingpulse_sdk/integrations/base.py +224 -0
- turingpulse_sdk/integrations/crewai.py +262 -0
- turingpulse_sdk/integrations/langgraph.py +872 -0
- turingpulse_sdk/integrations/llamaindex.py +268 -0
- turingpulse_sdk/kpi.py +104 -0
- turingpulse_sdk/llm_detector.py +348 -0
- turingpulse_sdk/plugin.py +554 -0
- turingpulse_sdk/registry.py +36 -0
- turingpulse_sdk/tracing.py +92 -0
- turingpulse_sdk/transcripts.py +69 -0
- turingpulse_sdk/trigger_state.py +30 -0
- turingpulse_sdk-1.0.0.dist-info/METADATA +14 -0
- turingpulse_sdk-1.0.0.dist-info/RECORD +29 -0
- turingpulse_sdk-1.0.0.dist-info/WHEEL +5 -0
- turingpulse_sdk-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|