openbox-langgraph-sdk-python 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.
@@ -0,0 +1,130 @@
1
+ """
2
+ OpenBox LangGraph SDK — Python port of @openbox/langgraph-sdk.
3
+
4
+ Provides OpenBox governance and observability for any compiled LangGraph graph.
5
+
6
+ Example:
7
+ >>> from openbox_langgraph import create_openbox_graph_handler
8
+ >>> governed = await create_openbox_graph_handler(
9
+ ... graph=my_compiled_graph,
10
+ ... api_url="https://...",
11
+ ... api_key="obx_live_...",
12
+ ... agent_name="MyAgent",
13
+ ... )
14
+ >>> result = await governed.ainvoke(
15
+ ... {"messages": [{"role": "user", "content": "Hello"}]},
16
+ ... config={"configurable": {"thread_id": "session-abc"}},
17
+ ... )
18
+ """
19
+
20
+ from openbox_langgraph.client import GovernanceClient, build_auth_headers
21
+ from openbox_langgraph.config import (
22
+ GovernanceConfig,
23
+ get_global_config,
24
+ initialize,
25
+ merge_config,
26
+ )
27
+ from openbox_langgraph.errors import (
28
+ ApprovalExpiredError,
29
+ ApprovalRejectedError,
30
+ ApprovalTimeoutError,
31
+ GovernanceBlockedError,
32
+ GovernanceHaltError,
33
+ GuardrailsValidationError,
34
+ OpenBoxAuthError,
35
+ OpenBoxError,
36
+ OpenBoxInsecureURLError,
37
+ OpenBoxNetworkError,
38
+ )
39
+ from openbox_langgraph.hitl import poll_until_decision
40
+ from openbox_langgraph.langgraph_handler import (
41
+ OpenBoxLangGraphHandler,
42
+ OpenBoxLangGraphHandlerOptions,
43
+ create_openbox_graph_handler,
44
+ )
45
+
46
+ # OTel exports (required dependency)
47
+ from openbox_langgraph.otel_setup import setup_opentelemetry_for_governance
48
+ from openbox_langgraph.span_processor import WorkflowSpanProcessor
49
+ from openbox_langgraph.tracing import create_span, traced
50
+ from openbox_langgraph.types import (
51
+ DEFAULT_HITL_CONFIG,
52
+ ApprovalResponse,
53
+ GovernanceVerdictResponse,
54
+ GuardrailsReason,
55
+ GuardrailsResult,
56
+ HITLConfig,
57
+ LangChainGovernanceEvent,
58
+ LangGraphStreamEvent,
59
+ Verdict,
60
+ WorkflowEventType,
61
+ WorkflowSpanBuffer,
62
+ highest_priority_verdict,
63
+ parse_approval_response,
64
+ parse_governance_response,
65
+ rfc3339_now,
66
+ safe_serialize,
67
+ to_server_event_type,
68
+ verdict_from_string,
69
+ verdict_priority,
70
+ verdict_requires_approval,
71
+ verdict_should_stop,
72
+ )
73
+ from openbox_langgraph.verdict_handler import (
74
+ VerdictContext,
75
+ enforce_verdict,
76
+ is_hitl_applicable,
77
+ lang_graph_event_to_context,
78
+ )
79
+
80
+ __all__ = [
81
+ "DEFAULT_HITL_CONFIG",
82
+ "ApprovalExpiredError",
83
+ "ApprovalRejectedError",
84
+ "ApprovalResponse",
85
+ "ApprovalTimeoutError",
86
+ "GovernanceBlockedError",
87
+ "GovernanceClient",
88
+ "GovernanceConfig",
89
+ "GovernanceHaltError",
90
+ "GovernanceVerdictResponse",
91
+ "GuardrailsReason",
92
+ "GuardrailsResult",
93
+ "GuardrailsValidationError",
94
+ "HITLConfig",
95
+ "LangChainGovernanceEvent",
96
+ "LangGraphStreamEvent",
97
+ "OpenBoxAuthError",
98
+ "OpenBoxError",
99
+ "OpenBoxInsecureURLError",
100
+ "OpenBoxLangGraphHandler",
101
+ "OpenBoxLangGraphHandlerOptions",
102
+ "OpenBoxNetworkError",
103
+ "Verdict",
104
+ "VerdictContext",
105
+ "WorkflowEventType",
106
+ "WorkflowSpanBuffer",
107
+ "WorkflowSpanProcessor",
108
+ "build_auth_headers",
109
+ "create_openbox_graph_handler",
110
+ "create_span",
111
+ "enforce_verdict",
112
+ "get_global_config",
113
+ "highest_priority_verdict",
114
+ "initialize",
115
+ "is_hitl_applicable",
116
+ "lang_graph_event_to_context",
117
+ "merge_config",
118
+ "parse_approval_response",
119
+ "parse_governance_response",
120
+ "poll_until_decision",
121
+ "rfc3339_now",
122
+ "safe_serialize",
123
+ "setup_opentelemetry_for_governance",
124
+ "to_server_event_type",
125
+ "traced",
126
+ "verdict_from_string",
127
+ "verdict_priority",
128
+ "verdict_requires_approval",
129
+ "verdict_should_stop",
130
+ ]
@@ -0,0 +1,358 @@
1
+ """OpenBox LangGraph SDK — Governance HTTP Client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from datetime import UTC
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from openbox_langgraph.errors import OpenBoxNetworkError
13
+ from openbox_langgraph.types import (
14
+ ApprovalResponse,
15
+ GovernanceVerdictResponse,
16
+ LangChainGovernanceEvent,
17
+ Verdict,
18
+ parse_approval_response,
19
+ to_server_event_type,
20
+ )
21
+
22
+ _SDK_VERSION = "0.1.0"
23
+
24
+
25
+ def build_auth_headers(api_key: str) -> dict[str, str]:
26
+ """Build standard auth headers for governance API calls.
27
+
28
+ Single source of truth — used by GovernanceClient and hook_governance.
29
+ """
30
+ return {
31
+ "Authorization": f"Bearer {api_key}",
32
+ "Content-Type": "application/json",
33
+ "User-Agent": f"OpenBox-LangGraph-SDK/{_SDK_VERSION}",
34
+ "X-OpenBox-SDK-Version": _SDK_VERSION,
35
+ }
36
+
37
+
38
+ @dataclass
39
+ class ApprovalPollParams:
40
+ """Parameters for an HITL approval poll request."""
41
+
42
+ workflow_id: str
43
+ run_id: str
44
+ activity_id: str
45
+
46
+
47
+ class GovernanceClient:
48
+ """Async HTTP client for the OpenBox Core governance API.
49
+
50
+ Uses persistent httpx.AsyncClient instances (lazy-init) to avoid the
51
+ overhead of creating a new TCP connection per governance call.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ *,
57
+ api_url: str,
58
+ api_key: str,
59
+ timeout: float = 30.0, # seconds
60
+ on_api_error: str = "fail_open",
61
+ ) -> None:
62
+ self._api_url = api_url.rstrip("/")
63
+ self._api_key = api_key
64
+ self._timeout = timeout # already in seconds
65
+ self._on_api_error = on_api_error
66
+ self._client: httpx.AsyncClient | None = None
67
+ self._sync_client: httpx.Client | None = None
68
+ self._cached_headers = build_auth_headers(api_key)
69
+ # Deduplication: prevent sending the same (activity_id, event_type) twice
70
+ # within the same workflow run. Keyed by (workflow_id, run_id) so it resets
71
+ # automatically on each new ainvoke() call.
72
+ self._dedup_run: tuple[str, str] | None = None
73
+ self._dedup_sent: set[tuple[str, str]] = set()
74
+
75
+ def _get_client(self) -> httpx.AsyncClient:
76
+ """Return or create the persistent async HTTP client."""
77
+ if self._client is None or self._client.is_closed:
78
+ self._client = httpx.AsyncClient(timeout=self._timeout)
79
+ return self._client
80
+
81
+ def _get_sync_client(self) -> httpx.Client:
82
+ """Return or create the persistent sync HTTP client."""
83
+ if self._sync_client is None or self._sync_client.is_closed:
84
+ self._sync_client = httpx.Client(timeout=self._timeout)
85
+ return self._sync_client
86
+
87
+ async def close(self) -> None:
88
+ """Close the underlying HTTP clients."""
89
+ if self._client and not self._client.is_closed:
90
+ await self._client.aclose()
91
+ self._client = None
92
+ if self._sync_client and not self._sync_client.is_closed:
93
+ self._sync_client.close()
94
+ self._sync_client = None
95
+
96
+ # ─────────────────────────────────────────────────────────────
97
+ # Public methods
98
+ # ─────────────────────────────────────────────────────────────
99
+
100
+ async def validate_api_key(self) -> None:
101
+ """Validate the API key against the server.
102
+
103
+ Raises:
104
+ OpenBoxAuthError: If the key is rejected (401/403).
105
+ OpenBoxNetworkError: If the server is unreachable.
106
+ """
107
+ from openbox_langgraph.errors import OpenBoxAuthError
108
+
109
+ try:
110
+ client = self._get_client()
111
+ response = await client.get(
112
+ f"{self._api_url}/api/v1/auth/validate",
113
+ headers=self._headers(),
114
+ )
115
+ if response.status_code in (401, 403):
116
+ msg = "Invalid API key. Check your API key at dashboard.openbox.ai"
117
+ raise OpenBoxAuthError(msg)
118
+ if not response.is_success:
119
+ msg = f"Cannot reach OpenBox Core at {self._api_url}: HTTP {response.status_code}"
120
+ raise OpenBoxNetworkError(msg)
121
+ except (OpenBoxAuthError, OpenBoxNetworkError):
122
+ raise
123
+ except Exception as e:
124
+ msg = f"Cannot reach OpenBox Core at {self._api_url}: {e}"
125
+ raise OpenBoxNetworkError(msg) from e
126
+
127
+ def _is_duplicate(
128
+ self, workflow_id: str, run_id: str, activity_id: str, event_type: str
129
+ ) -> bool:
130
+ """Return True if this (activity_id, event_type) was already sent in this run.
131
+
132
+ Resets automatically when (workflow_id, run_id) changes — i.e. on each new
133
+ ainvoke() call, which generates a fresh workflow_id + run_id pair.
134
+ Hook events (evaluate_raw) are never checked here — multiple hooks per
135
+ activity are expected and valid.
136
+ """
137
+ current_run = (workflow_id, run_id)
138
+ if self._dedup_run != current_run:
139
+ self._dedup_run = current_run
140
+ self._dedup_sent = set()
141
+ key = (activity_id, event_type)
142
+ if key in self._dedup_sent:
143
+ return True
144
+ self._dedup_sent.add(key)
145
+ return False
146
+
147
+ async def evaluate_event(
148
+ self, event: LangChainGovernanceEvent
149
+ ) -> GovernanceVerdictResponse | None:
150
+ """Send a governance event to OpenBox Core and return the verdict.
151
+
152
+ Returns `None` on network failure when `on_api_error` is `fail_open`.
153
+ Silently drops duplicate (activity_id, event_type) pairs within the same run.
154
+
155
+ Args:
156
+ event: The governance event payload to evaluate.
157
+
158
+ Raises:
159
+ OpenBoxNetworkError: On network failure when `on_api_error` is `fail_closed`.
160
+ """
161
+ server_event_type = to_server_event_type(event.event_type)
162
+ if event.activity_id and self._is_duplicate(
163
+ event.workflow_id, event.run_id, event.activity_id, server_event_type
164
+ ):
165
+ if os.environ.get("OPENBOX_DEBUG") == "1":
166
+ print(
167
+ f"[OpenBox Debug] dedup: dropped duplicate {server_event_type}"
168
+ f" activity_id={event.activity_id}"
169
+ )
170
+ return None
171
+
172
+ payload = event.to_dict()
173
+ payload["event_type"] = server_event_type
174
+ payload["task_queue"] = event.task_queue or "langgraph"
175
+ payload["source"] = "workflow-telemetry"
176
+
177
+ if os.environ.get("OPENBOX_DEBUG") == "1":
178
+ import json
179
+ print(
180
+ f"[OpenBox Debug] governance request: {json.dumps(payload, indent=2, default=str)}"
181
+ )
182
+
183
+ try:
184
+ client = self._get_client()
185
+ response = await client.post(
186
+ f"{self._api_url}/api/v1/governance/evaluate",
187
+ headers=self._headers(),
188
+ json=payload,
189
+ )
190
+
191
+ if not response.is_success:
192
+ if self._on_api_error == "fail_closed":
193
+ msg = f"Governance API error: HTTP {response.status_code}"
194
+ raise OpenBoxNetworkError(msg)
195
+ return None
196
+
197
+ data = response.json()
198
+ return GovernanceVerdictResponse.from_dict(data)
199
+
200
+ except OpenBoxNetworkError:
201
+ raise
202
+ except Exception as e:
203
+ if self._on_api_error == "fail_closed":
204
+ msg = f"Governance API unreachable: {e}"
205
+ raise OpenBoxNetworkError(msg) from e
206
+ return None
207
+
208
+ def evaluate_event_sync(
209
+ self, event: LangChainGovernanceEvent
210
+ ) -> GovernanceVerdictResponse | None:
211
+ """Sync version of evaluate_event using httpx.Client.
212
+
213
+ Used by sync middleware hooks (invoke/stream) to avoid asyncio.run()
214
+ teardown killing the HTTP connection before Core finishes processing.
215
+ """
216
+ server_event_type = to_server_event_type(event.event_type)
217
+ if event.activity_id and self._is_duplicate(
218
+ event.workflow_id, event.run_id, event.activity_id, server_event_type
219
+ ):
220
+ return None
221
+
222
+ payload = event.to_dict()
223
+ payload["event_type"] = server_event_type
224
+ payload["task_queue"] = event.task_queue or "langgraph"
225
+ payload["source"] = "workflow-telemetry"
226
+
227
+ if os.environ.get("OPENBOX_DEBUG") == "1":
228
+ import json
229
+ print(
230
+ "[OpenBox Debug] sync governance request:"
231
+ f" {json.dumps(payload, indent=2, default=str)}"
232
+ )
233
+
234
+ try:
235
+ client = self._get_sync_client()
236
+ response = client.post(
237
+ f"{self._api_url}/api/v1/governance/evaluate",
238
+ headers=self._headers(),
239
+ json=payload,
240
+ )
241
+
242
+ if not response.is_success:
243
+ if self._on_api_error == "fail_closed":
244
+ msg = f"Governance API error: HTTP {response.status_code}"
245
+ raise OpenBoxNetworkError(msg)
246
+ return None
247
+
248
+ data = response.json()
249
+ return GovernanceVerdictResponse.from_dict(data)
250
+
251
+ except OpenBoxNetworkError:
252
+ raise
253
+ except Exception as e:
254
+ if self._on_api_error == "fail_closed":
255
+ msg = f"Governance API unreachable: {e}"
256
+ raise OpenBoxNetworkError(msg) from e
257
+ return None
258
+
259
+ async def poll_approval(
260
+ self, params: ApprovalPollParams
261
+ ) -> ApprovalResponse | None:
262
+ """Poll for HITL approval status.
263
+
264
+ Returns `None` on network failure so the caller can retry.
265
+
266
+ Args:
267
+ params: Identifiers for the pending approval.
268
+ """
269
+ try:
270
+ client = self._get_client()
271
+ response = await client.post(
272
+ f"{self._api_url}/api/v1/governance/approval",
273
+ headers=self._headers(),
274
+ json={
275
+ "workflow_id": params.workflow_id,
276
+ "run_id": params.run_id,
277
+ "activity_id": params.activity_id,
278
+ },
279
+ )
280
+
281
+ if not response.is_success:
282
+ return None
283
+
284
+ data = response.json()
285
+ parsed = parse_approval_response(data)
286
+
287
+ # SDK-side expiration check
288
+ if parsed.approval_expiration_time and not parsed.expired:
289
+ from datetime import datetime
290
+ expiry = datetime.fromisoformat(
291
+ parsed.approval_expiration_time.replace("Z", "+00:00")
292
+ )
293
+ if expiry < datetime.now(tz=UTC):
294
+ parsed.expired = True
295
+
296
+ return parsed
297
+
298
+ except Exception:
299
+ return None
300
+
301
+ async def evaluate_raw(
302
+ self, payload: dict[str, Any]
303
+ ) -> dict[str, Any] | None:
304
+ """Send a pre-built payload to the governance evaluate endpoint.
305
+
306
+ Used by hook-level governance where the payload is fully assembled
307
+ by the caller (no event_type translation needed).
308
+
309
+ Args:
310
+ payload: The raw dict to POST to `/api/v1/governance/evaluate`.
311
+ """
312
+ if os.environ.get("OPENBOX_DEBUG") == "1":
313
+ import json
314
+ print(
315
+ f"[OpenBox Debug] span hook request: {json.dumps(payload, indent=2, default=str)}"
316
+ )
317
+
318
+ try:
319
+ client = self._get_client()
320
+ response = await client.post(
321
+ f"{self._api_url}/api/v1/governance/evaluate",
322
+ headers=self._headers(),
323
+ json=payload,
324
+ )
325
+
326
+ if not response.is_success:
327
+ if os.environ.get("OPENBOX_DEBUG") == "1":
328
+ print(
329
+ f"[OpenBox Debug] span hook error: HTTP {response.status_code}"
330
+ f" body={response.text[:500]}"
331
+ )
332
+ if self._on_api_error == "fail_closed":
333
+ msg = f"Governance API error: HTTP {response.status_code}"
334
+ raise OpenBoxNetworkError(msg)
335
+ return None
336
+
337
+ data = response.json()
338
+ return data # type: ignore[no-any-return]
339
+
340
+ except OpenBoxNetworkError:
341
+ raise
342
+ except Exception as e:
343
+ if self._on_api_error == "fail_closed":
344
+ msg = f"Governance API unreachable: {e}"
345
+ raise OpenBoxNetworkError(msg) from e
346
+ return None
347
+
348
+ @staticmethod
349
+ def halt_response(reason: str) -> GovernanceVerdictResponse:
350
+ """Build a fail-closed HALT response for when the API is unreachable."""
351
+ return GovernanceVerdictResponse(verdict=Verdict.HALT, reason=reason)
352
+
353
+ # ─────────────────────────────────────────────────────────────
354
+ # Private helpers
355
+ # ─────────────────────────────────────────────────────────────
356
+
357
+ def _headers(self) -> dict[str, str]:
358
+ return self._cached_headers