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.
- openbox_langgraph/__init__.py +130 -0
- openbox_langgraph/client.py +358 -0
- openbox_langgraph/config.py +264 -0
- openbox_langgraph/db_governance_hooks.py +897 -0
- openbox_langgraph/errors.py +114 -0
- openbox_langgraph/file_governance_hooks.py +413 -0
- openbox_langgraph/hitl.py +88 -0
- openbox_langgraph/hook_governance.py +397 -0
- openbox_langgraph/http_governance_hooks.py +695 -0
- openbox_langgraph/langgraph_handler.py +1616 -0
- openbox_langgraph/otel_setup.py +468 -0
- openbox_langgraph/span_processor.py +253 -0
- openbox_langgraph/tracing.py +352 -0
- openbox_langgraph/types.py +485 -0
- openbox_langgraph/verdict_handler.py +203 -0
- openbox_langgraph_sdk_python-0.1.0.dist-info/METADATA +492 -0
- openbox_langgraph_sdk_python-0.1.0.dist-info/RECORD +18 -0
- openbox_langgraph_sdk_python-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""OpenBox LangGraph SDK — Configuration & initialization."""
|
|
2
|
+
|
|
3
|
+
# NOTE: No module-level logging import — lazy-loaded to avoid sandbox restrictions
|
|
4
|
+
# where applicable (mirrors openbox-temporal-sdk-python pattern).
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from openbox_langgraph.errors import (
|
|
13
|
+
OpenBoxAuthError,
|
|
14
|
+
OpenBoxInsecureURLError,
|
|
15
|
+
OpenBoxNetworkError,
|
|
16
|
+
)
|
|
17
|
+
from openbox_langgraph.types import DEFAULT_HITL_CONFIG, HITLConfig
|
|
18
|
+
|
|
19
|
+
# API key format pattern (obx_live_... or obx_test_...)
|
|
20
|
+
_API_KEY_PATTERN = re.compile(r"^obx_(live|test)_[a-zA-Z0-9_]+$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_logger():
|
|
24
|
+
"""Lazy logger import."""
|
|
25
|
+
import logging
|
|
26
|
+
return logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
30
|
+
# API key / URL validation
|
|
31
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
32
|
+
|
|
33
|
+
def validate_api_key_format(api_key: str) -> bool:
|
|
34
|
+
"""Return True if the API key matches the expected `obx_live_*` / `obx_test_*` format."""
|
|
35
|
+
return bool(_API_KEY_PATTERN.match(api_key))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate_url_security(api_url: str) -> None:
|
|
39
|
+
"""Raise `OpenBoxInsecureURLError` if the URL uses HTTP on a non-localhost host."""
|
|
40
|
+
from urllib.parse import urlparse # lazy — avoids sandbox os.stat
|
|
41
|
+
|
|
42
|
+
parsed = urlparse(api_url)
|
|
43
|
+
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
|
44
|
+
if parsed.scheme == "http" and not is_localhost:
|
|
45
|
+
msg = (
|
|
46
|
+
f"Insecure HTTP URL detected: {api_url}. "
|
|
47
|
+
"Use HTTPS for non-localhost URLs to protect API keys in transit."
|
|
48
|
+
)
|
|
49
|
+
raise OpenBoxInsecureURLError(msg)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
53
|
+
# GovernanceConfig
|
|
54
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class GovernanceConfig:
|
|
58
|
+
"""Full resolved governance configuration for the handler."""
|
|
59
|
+
|
|
60
|
+
on_api_error: str = "fail_open" # "fail_open" | "fail_closed"
|
|
61
|
+
api_timeout: float = 30.0 # seconds (not ms)
|
|
62
|
+
send_chain_start_event: bool = True
|
|
63
|
+
send_chain_end_event: bool = True
|
|
64
|
+
send_tool_start_event: bool = True
|
|
65
|
+
send_tool_end_event: bool = True
|
|
66
|
+
send_llm_start_event: bool = True
|
|
67
|
+
send_llm_end_event: bool = True
|
|
68
|
+
skip_chain_types: set[str] = field(default_factory=set)
|
|
69
|
+
skip_tool_types: set[str] = field(default_factory=set)
|
|
70
|
+
hitl: HITLConfig = field(default_factory=HITLConfig)
|
|
71
|
+
session_id: str | None = None
|
|
72
|
+
agent_name: str | None = None
|
|
73
|
+
task_queue: str = "langgraph"
|
|
74
|
+
use_native_interrupt: bool = False
|
|
75
|
+
root_node_names: set[str] = field(default_factory=set)
|
|
76
|
+
tool_type_map: dict[str, str] = field(default_factory=dict)
|
|
77
|
+
"""Optional mapping of tool name → tool_type for execution tree classification.
|
|
78
|
+
|
|
79
|
+
Example: {"search_web": "http", "query_db": "database", "write_file": "builtin"}
|
|
80
|
+
Supported values: "http", "database", "builtin", "a2a".
|
|
81
|
+
If a tool is not listed, no prefix is added to the label (bare tool name shown).
|
|
82
|
+
A2A is set automatically when subagent_name is resolved — no need to list "task" here.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
DEFAULT_GOVERNANCE_CONFIG = GovernanceConfig()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def merge_config(partial: dict[str, Any] | None = None) -> GovernanceConfig:
|
|
90
|
+
"""Merge a partial config dict over the defaults and return a `GovernanceConfig`."""
|
|
91
|
+
if not partial:
|
|
92
|
+
return GovernanceConfig()
|
|
93
|
+
|
|
94
|
+
hitl_partial = partial.get("hitl") or {}
|
|
95
|
+
if isinstance(hitl_partial, HITLConfig):
|
|
96
|
+
hitl = hitl_partial
|
|
97
|
+
else:
|
|
98
|
+
# Accept poll_interval_ms or poll_interval_s; same for max_wait
|
|
99
|
+
hitl = HITLConfig(
|
|
100
|
+
enabled=hitl_partial.get("enabled", DEFAULT_HITL_CONFIG.enabled),
|
|
101
|
+
poll_interval_ms=hitl_partial.get(
|
|
102
|
+
"poll_interval_ms", DEFAULT_HITL_CONFIG.poll_interval_ms
|
|
103
|
+
),
|
|
104
|
+
skip_tool_types=set(hitl_partial.get("skip_tool_types", [])),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def _to_set(v: Any) -> set[str]:
|
|
108
|
+
if isinstance(v, set):
|
|
109
|
+
return v
|
|
110
|
+
if isinstance(v, (list, tuple)):
|
|
111
|
+
return set(v)
|
|
112
|
+
return set()
|
|
113
|
+
|
|
114
|
+
# api_timeout: accept both seconds (float ≤ 600) and milliseconds (int > 600)
|
|
115
|
+
raw_timeout = partial.get("api_timeout", DEFAULT_GOVERNANCE_CONFIG.api_timeout)
|
|
116
|
+
api_timeout = float(raw_timeout) if raw_timeout <= 600 else float(raw_timeout) / 1000.0
|
|
117
|
+
|
|
118
|
+
raw_tool_type_map = partial.get("tool_type_map")
|
|
119
|
+
tool_type_map: dict[str, str] = raw_tool_type_map if isinstance(raw_tool_type_map, dict) else {}
|
|
120
|
+
|
|
121
|
+
return GovernanceConfig(
|
|
122
|
+
on_api_error=partial.get("on_api_error", DEFAULT_GOVERNANCE_CONFIG.on_api_error),
|
|
123
|
+
api_timeout=api_timeout,
|
|
124
|
+
send_chain_start_event=partial.get("send_chain_start_event", True),
|
|
125
|
+
send_chain_end_event=partial.get("send_chain_end_event", True),
|
|
126
|
+
send_tool_start_event=partial.get("send_tool_start_event", True),
|
|
127
|
+
send_tool_end_event=partial.get("send_tool_end_event", True),
|
|
128
|
+
send_llm_start_event=partial.get("send_llm_start_event", True),
|
|
129
|
+
send_llm_end_event=partial.get("send_llm_end_event", True),
|
|
130
|
+
skip_chain_types=_to_set(partial.get("skip_chain_types")),
|
|
131
|
+
skip_tool_types=_to_set(partial.get("skip_tool_types")),
|
|
132
|
+
hitl=hitl,
|
|
133
|
+
session_id=partial.get("session_id"),
|
|
134
|
+
agent_name=partial.get("agent_name"),
|
|
135
|
+
task_queue=partial.get("task_queue", "langgraph"),
|
|
136
|
+
use_native_interrupt=partial.get("use_native_interrupt", False),
|
|
137
|
+
root_node_names=_to_set(partial.get("root_node_names")),
|
|
138
|
+
tool_type_map=tool_type_map,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
143
|
+
# Global Config Singleton
|
|
144
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class _GlobalConfigState:
|
|
148
|
+
api_url: str = ""
|
|
149
|
+
api_key: str = ""
|
|
150
|
+
governance_timeout: float = 30.0 # seconds
|
|
151
|
+
|
|
152
|
+
def configure(self, api_url: str, api_key: str, governance_timeout: float = 30.0) -> None:
|
|
153
|
+
self.api_url = api_url.rstrip("/")
|
|
154
|
+
self.api_key = api_key
|
|
155
|
+
self.governance_timeout = governance_timeout
|
|
156
|
+
|
|
157
|
+
def __repr__(self) -> str:
|
|
158
|
+
if self.api_key and len(self.api_key) > 8:
|
|
159
|
+
masked = f"obx_****{self.api_key[-4:]}"
|
|
160
|
+
elif self.api_key:
|
|
161
|
+
masked = "****"
|
|
162
|
+
else:
|
|
163
|
+
masked = ""
|
|
164
|
+
return (
|
|
165
|
+
f"_GlobalConfigState(api_url={self.api_url!r}, "
|
|
166
|
+
f"api_key={masked!r}, "
|
|
167
|
+
f"governance_timeout={self.governance_timeout})"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def is_configured(self) -> bool:
|
|
171
|
+
return bool(self.api_url and self.api_key)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
_global_config = _GlobalConfigState()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_global_config() -> _GlobalConfigState:
|
|
178
|
+
"""Return the global SDK configuration singleton."""
|
|
179
|
+
return _global_config
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
183
|
+
# Server-side API key validation (sync, using urllib — no httpx at module level)
|
|
184
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
185
|
+
|
|
186
|
+
def _validate_api_key_with_server(
|
|
187
|
+
api_url: str, api_key: str, timeout: float
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Validate API key by calling /api/v1/auth/validate endpoint (synchronous).
|
|
190
|
+
|
|
191
|
+
Uses urllib to avoid importing httpx at module level, matching the
|
|
192
|
+
openbox-temporal-sdk-python pattern for sandbox compatibility.
|
|
193
|
+
"""
|
|
194
|
+
from urllib.error import HTTPError, URLError # lazy
|
|
195
|
+
from urllib.request import Request, urlopen # lazy
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
req = Request(
|
|
199
|
+
f"{api_url}/api/v1/auth/validate",
|
|
200
|
+
headers={
|
|
201
|
+
"Authorization": f"Bearer {api_key}",
|
|
202
|
+
"Content-Type": "application/json",
|
|
203
|
+
"User-Agent": "OpenBox-LangGraph-SDK/0.1.0",
|
|
204
|
+
},
|
|
205
|
+
method="GET",
|
|
206
|
+
)
|
|
207
|
+
with urlopen(req, timeout=timeout) as response:
|
|
208
|
+
if response.getcode() != 200:
|
|
209
|
+
msg = "Invalid API key. Check your API key at dashboard.openbox.ai"
|
|
210
|
+
raise OpenBoxAuthError(msg)
|
|
211
|
+
_get_logger().info("OpenBox API key validated successfully")
|
|
212
|
+
|
|
213
|
+
except HTTPError as e:
|
|
214
|
+
if e.code in (401, 403):
|
|
215
|
+
msg = "Invalid API key. Check your API key at dashboard.openbox.ai"
|
|
216
|
+
raise OpenBoxAuthError(msg) from e
|
|
217
|
+
msg = f"Cannot reach OpenBox Core at {api_url}: HTTP {e.code}"
|
|
218
|
+
raise OpenBoxNetworkError(msg) from e
|
|
219
|
+
except URLError as e:
|
|
220
|
+
msg = f"Cannot reach OpenBox Core at {api_url}: {e.reason}"
|
|
221
|
+
raise OpenBoxNetworkError(msg) from e
|
|
222
|
+
except (OpenBoxAuthError, OpenBoxNetworkError):
|
|
223
|
+
raise
|
|
224
|
+
except Exception as e:
|
|
225
|
+
msg = f"Cannot reach OpenBox Core at {api_url}: {e}"
|
|
226
|
+
raise OpenBoxNetworkError(msg) from e
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
230
|
+
# initialize() — synchronous, matching openbox-temporal-sdk-python
|
|
231
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
232
|
+
|
|
233
|
+
def initialize(
|
|
234
|
+
api_url: str,
|
|
235
|
+
api_key: str,
|
|
236
|
+
governance_timeout: float = 30.0,
|
|
237
|
+
validate: bool = True,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Initialize the OpenBox LangGraph SDK with credentials.
|
|
240
|
+
|
|
241
|
+
Synchronous — safe to call at module level or from any context.
|
|
242
|
+
Validates the API key format and optionally pings the server.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
api_url: Base URL of your OpenBox Core instance.
|
|
246
|
+
api_key: API key in `obx_live_*` or `obx_test_*` format.
|
|
247
|
+
governance_timeout: HTTP timeout in **seconds** for governance calls (default 30.0).
|
|
248
|
+
validate: If True, validates the API key against the server on startup.
|
|
249
|
+
"""
|
|
250
|
+
validate_url_security(api_url)
|
|
251
|
+
|
|
252
|
+
if not validate_api_key_format(api_key):
|
|
253
|
+
msg = (
|
|
254
|
+
f"Invalid API key format. Expected 'obx_live_*' or 'obx_test_*', "
|
|
255
|
+
f"got: '{api_key[:15]}...' (showing first 15 chars)"
|
|
256
|
+
)
|
|
257
|
+
raise OpenBoxAuthError(msg)
|
|
258
|
+
|
|
259
|
+
_global_config.configure(api_url.rstrip("/"), api_key, governance_timeout)
|
|
260
|
+
|
|
261
|
+
if validate:
|
|
262
|
+
_validate_api_key_with_server(api_url.rstrip("/"), api_key, governance_timeout)
|
|
263
|
+
|
|
264
|
+
_get_logger().info(f"OpenBox LangGraph SDK initialized with API URL: {api_url}")
|