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,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}")