polaris-python-ai 0.0.1.dev2__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,315 @@
1
+ # Tencent is pleased to support the open source community by making polaris-python-ai available.
2
+ #
3
+ # Copyright (C) 2026 Tencent. All rights reserved.
4
+ #
5
+ # Licensed under the BSD 3-Clause License (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://opensource.org/licenses/BSD-3-Clause
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software distributed
12
+ # under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
13
+ # CONDITIONS OF ANY KIND, either express or implied. See the License for the
14
+ # specific language governing permissions and limitations under the License.
15
+
16
+ """A Polaris-routed wrapper around LangGraph's native ``RemoteGraph``."""
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import time
22
+
23
+ from polaris.pkg.model.service import InstanceType
24
+
25
+ from polaris_ai._async import await_completion
26
+ from polaris_ai.log import get_ai_logger
27
+ from polaris_ai.metrics import PolarisCallReporter
28
+
29
+
30
+ def _is_timeout(error: BaseException) -> bool:
31
+ seen = set()
32
+ current: BaseException | None = error
33
+ while current is not None and id(current) not in seen:
34
+ seen.add(id(current))
35
+ if isinstance(current, (TimeoutError, asyncio.TimeoutError)):
36
+ return True
37
+ if current.__class__.__name__ in {
38
+ "TimeoutException",
39
+ "ReadTimeout",
40
+ "ConnectTimeout",
41
+ "WriteTimeout",
42
+ "PoolTimeout",
43
+ }:
44
+ return True
45
+ current = current.__cause__ or current.__context__
46
+ return False
47
+
48
+
49
+ class PolarisRemoteGraph:
50
+ """Resolve a graph endpoint from Polaris and invoke LangGraph's RemoteGraph.
51
+
52
+ Each call refreshes discovery. The native client is reused while the selected
53
+ URL is unchanged and rebuilt after failover. The supplied resolver remains
54
+ borrowed unless ``owns_resolver=True``.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ resolver,
60
+ graph_id: str,
61
+ *,
62
+ service: str = "",
63
+ graph_factory=None,
64
+ api_key: str = "",
65
+ headers: dict[str, str] | None = None,
66
+ reporter=None,
67
+ caller_service: str = "",
68
+ labels: str = "",
69
+ owns_resolver: bool = False,
70
+ logger=None,
71
+ ):
72
+ if resolver is None:
73
+ raise ValueError("A PolarisLangGraphResolver is required")
74
+ if not isinstance(graph_id, str) or not graph_id.strip():
75
+ raise ValueError("graph_id is required")
76
+ self.graph_id = graph_id.strip()
77
+ self.service = service.strip() or self.graph_id
78
+ self._resolver = resolver
79
+ self._graph_factory = graph_factory
80
+ self._api_key = api_key
81
+ self._headers = dict(headers or {})
82
+ self._owns_resolver = owns_resolver
83
+ self._graph = None
84
+ self._graph_url = ""
85
+ self._closed = False
86
+ self.logger = logger if logger is not None else get_ai_logger("langgraph")
87
+ self._reporter = reporter
88
+ if self._reporter is None and getattr(resolver, "consumer", None) is not None:
89
+ self._reporter = PolarisCallReporter(
90
+ resolver.consumer,
91
+ getattr(resolver, "namespace", "default"),
92
+ caller_service=caller_service,
93
+ labels=labels,
94
+ logger=self.logger,
95
+ )
96
+
97
+ def _check_open(self):
98
+ if self._closed:
99
+ raise RuntimeError("Polaris remote graph is closed")
100
+
101
+ @staticmethod
102
+ def _native_type():
103
+ try:
104
+ try:
105
+ from langgraph.pregel.remote import RemoteGraph
106
+ except ImportError:
107
+ from langgraph.pregel import RemoteGraph
108
+ except ImportError as error:
109
+ raise ImportError(
110
+ "LangGraph integration requires "
111
+ "pip install 'polaris-python-ai[langgraph]'."
112
+ ) from error
113
+ return RemoteGraph
114
+
115
+ def _select_graph(self, endpoint):
116
+ if self._graph is not None and self._graph_url == endpoint.url:
117
+ return self._graph, None
118
+ factory = self._graph_factory or self._native_type()
119
+ self.logger.debug(
120
+ "Creating LangGraph client: graph=%s service=%s url=%s",
121
+ self.graph_id,
122
+ self.service,
123
+ endpoint.url,
124
+ )
125
+ previous = self._graph
126
+ self._graph = factory(
127
+ self.graph_id,
128
+ url=endpoint.url,
129
+ api_key=self._api_key or None,
130
+ headers=self._headers or None,
131
+ )
132
+ self._graph_url = endpoint.url
133
+ return self._graph, previous
134
+
135
+ @staticmethod
136
+ def _close_sync_graph(graph):
137
+ client = getattr(graph, "sync_client", None)
138
+ close = getattr(client, "close", None)
139
+ if callable(close):
140
+ close()
141
+
142
+ @staticmethod
143
+ async def _close_async_graph(graph):
144
+ client = getattr(graph, "client", None)
145
+ close = getattr(client, "aclose", None)
146
+ if callable(close):
147
+ await close()
148
+ sync_client = getattr(graph, "sync_client", None)
149
+ sync_close = getattr(sync_client, "close", None)
150
+ if callable(sync_close):
151
+ await await_completion(asyncio.to_thread(sync_close))
152
+
153
+ @property
154
+ def remote_graph(self):
155
+ return self._graph
156
+
157
+ def invoke(self, input, config=None, **kwargs):
158
+ """Execute a synchronous graph call and report its Polaris outcome."""
159
+ self._check_open()
160
+ endpoint = self._resolver.resolve(self.graph_id, self.service)
161
+ started_at = time.monotonic()
162
+ try:
163
+ graph, previous = self._select_graph(endpoint)
164
+ if previous is not None:
165
+ self._close_sync_graph(previous)
166
+ result = graph.invoke(input, config, **kwargs)
167
+ except BaseException as error:
168
+ self._report_sync(
169
+ endpoint,
170
+ started_at,
171
+ method="langgraph.invoke",
172
+ success=False,
173
+ timeout=_is_timeout(error),
174
+ )
175
+ raise
176
+ self._report_sync(endpoint, started_at, method="langgraph.invoke", success=True)
177
+ return result
178
+
179
+ async def ainvoke(self, input, config=None, **kwargs):
180
+ """Execute an asynchronous graph call and report its Polaris outcome."""
181
+ self._check_open()
182
+ endpoint = await self._resolver.async_resolve(self.graph_id, self.service)
183
+ started_at = time.monotonic()
184
+ try:
185
+ graph, previous = self._select_graph(endpoint)
186
+ if previous is not None:
187
+ await self._close_async_graph(previous)
188
+ result = await graph.ainvoke(input, config, **kwargs)
189
+ except BaseException as error:
190
+ await self._report(
191
+ endpoint,
192
+ started_at,
193
+ method="langgraph.invoke",
194
+ success=False,
195
+ timeout=_is_timeout(error),
196
+ )
197
+ raise
198
+ await self._report(endpoint, started_at, method="langgraph.invoke", success=True)
199
+ return result
200
+
201
+ def stream(self, input, config=None, **kwargs):
202
+ """Stream a synchronous graph call, reporting after completion or failure."""
203
+ self._check_open()
204
+ endpoint = self._resolver.resolve(self.graph_id, self.service)
205
+ started_at = time.monotonic()
206
+ try:
207
+ graph, previous = self._select_graph(endpoint)
208
+ if previous is not None:
209
+ self._close_sync_graph(previous)
210
+ yield from graph.stream(input, config, **kwargs)
211
+ except BaseException as error:
212
+ self._report_sync(
213
+ endpoint,
214
+ started_at,
215
+ method="langgraph.stream",
216
+ success=False,
217
+ timeout=_is_timeout(error),
218
+ )
219
+ raise
220
+ self._report_sync(endpoint, started_at, method="langgraph.stream", success=True)
221
+
222
+ async def astream(self, input, config=None, **kwargs):
223
+ """Asynchronously stream a graph call, reporting after completion or failure."""
224
+ self._check_open()
225
+ endpoint = await self._resolver.async_resolve(self.graph_id, self.service)
226
+ started_at = time.monotonic()
227
+ try:
228
+ graph, previous = self._select_graph(endpoint)
229
+ if previous is not None:
230
+ await self._close_async_graph(previous)
231
+ async for chunk in graph.astream(input, config, **kwargs):
232
+ yield chunk
233
+ except BaseException as error:
234
+ await self._report(
235
+ endpoint,
236
+ started_at,
237
+ method="langgraph.stream",
238
+ success=False,
239
+ timeout=_is_timeout(error),
240
+ )
241
+ raise
242
+ await self._report(endpoint, started_at, method="langgraph.stream", success=True)
243
+
244
+ def _report_sync(self, endpoint, started_at, *, method, success, timeout=False):
245
+ if self._reporter is None:
246
+ return
247
+ self._reporter.report_sync(
248
+ service=endpoint.service,
249
+ host=endpoint.host,
250
+ port=endpoint.port,
251
+ method=method,
252
+ instance_type=InstanceType.LANGGRAPH,
253
+ started_at=started_at,
254
+ success=success,
255
+ timeout=timeout,
256
+ )
257
+
258
+ async def _report(self, endpoint, started_at, *, method, success, timeout=False):
259
+ if self._reporter is None:
260
+ return
261
+ await self._reporter.report(
262
+ service=endpoint.service,
263
+ host=endpoint.host,
264
+ port=endpoint.port,
265
+ method=method,
266
+ instance_type=InstanceType.LANGGRAPH,
267
+ started_at=started_at,
268
+ success=success,
269
+ timeout=timeout,
270
+ )
271
+
272
+ def invalidate(self):
273
+ """Force endpoint discovery and native-client recreation on the next call."""
274
+ self._graph = None
275
+ self._graph_url = ""
276
+ self._resolver.invalidate(self.graph_id, self.service)
277
+
278
+ def close(self):
279
+ if self._closed:
280
+ return
281
+ self._closed = True
282
+ self._close_sync_graph(self._graph)
283
+ self._graph = None
284
+ self._graph_url = ""
285
+ if self._owns_resolver:
286
+ self._resolver.close()
287
+
288
+ async def aclose(self):
289
+ if self._closed:
290
+ return
291
+ if self._owns_resolver:
292
+ await await_completion(self._resolver.async_close())
293
+ await self._close_async_graph(self._graph)
294
+ self._closed = True
295
+ self._graph = None
296
+ self._graph_url = ""
297
+ else:
298
+ await self._close_async_graph(self._graph)
299
+ self._closed = True
300
+ self._graph = None
301
+ self._graph_url = ""
302
+
303
+ def __enter__(self):
304
+ self._check_open()
305
+ return self
306
+
307
+ def __exit__(self, *_):
308
+ self.close()
309
+
310
+ async def __aenter__(self):
311
+ self._check_open()
312
+ return self
313
+
314
+ async def __aexit__(self, *_):
315
+ await self.aclose()
@@ -0,0 +1,226 @@
1
+ # Tencent is pleased to support the open source community by making polaris-python-ai available.
2
+ #
3
+ # Copyright (C) 2026 Tencent. All rights reserved.
4
+ #
5
+ # Licensed under the BSD 3-Clause License (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://opensource.org/licenses/BSD-3-Clause
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software distributed
12
+ # under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
13
+ # CONDITIONS OF ANY KIND, either express or implied. See the License for the
14
+ # specific language governing permissions and limitations under the License.
15
+
16
+ """Resolve healthy LangGraph Server endpoints through Polaris discovery."""
17
+
18
+ import asyncio
19
+ from dataclasses import dataclass
20
+ from threading import RLock
21
+ from urllib.parse import urlsplit, urlunsplit
22
+
23
+ from polaris.api.consumer import ConsumerAPI
24
+ from polaris.pkg.model.error import SDKError
25
+ from polaris.pkg.model.service import GetHealthyInstancesRequest
26
+
27
+ from polaris_ai._async import await_completion
28
+ from polaris_ai.log import get_ai_logger
29
+
30
+ from .registry import META_GRAPH_ID, META_PATH, META_PROTOCOL
31
+
32
+
33
+ class LangGraphEndpointNotFoundError(LookupError):
34
+ """No healthy LangGraph instance matched the requested service and graph."""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class LangGraphEndpoint:
39
+ namespace: str
40
+ service: str
41
+ graph_id: str
42
+ host: str
43
+ port: int
44
+ protocol: str
45
+ path: str
46
+
47
+ @property
48
+ def url(self) -> str:
49
+ host = f"[{self.host}]" if ":" in self.host and not self.host.startswith("[") else self.host
50
+ path = self.path if self.path.startswith("/") else f"/{self.path}"
51
+ return urlunsplit((self.protocol, f"{host}:{self.port}", path, "", ""))
52
+
53
+
54
+ class PolarisLangGraphResolver:
55
+ """Keep a sticky endpoint while refreshing discovery for every lookup."""
56
+
57
+ def __init__(
58
+ self,
59
+ sdk_context,
60
+ namespace: str = "default",
61
+ *,
62
+ timeout: int = 0,
63
+ logger=None,
64
+ ):
65
+ if sdk_context is None or sdk_context.is_destroy():
66
+ raise ValueError("An active SDKContext is required")
67
+ if type(timeout) is not int or timeout < 0:
68
+ raise ValueError("timeout must be a nonnegative integer")
69
+ self.namespace = namespace.strip() or "default"
70
+ self.timeout = timeout
71
+ self.logger = logger if logger is not None else get_ai_logger("langgraph")
72
+ self._context = sdk_context
73
+ self._consumer = ConsumerAPI(sdk_context)
74
+ self._cache: dict[tuple[str, str], LangGraphEndpoint] = {}
75
+ self._lock = RLock()
76
+ self._closed = False
77
+
78
+ def _check_open(self):
79
+ if self._closed or self._context.is_destroy():
80
+ raise RuntimeError("LangGraph resolver or SDKContext is closed")
81
+
82
+ @staticmethod
83
+ def _endpoint_key(graph_id: str, service: str) -> tuple[str, str]:
84
+ if not isinstance(graph_id, str) or not graph_id.strip():
85
+ raise ValueError("graph_id is required")
86
+ if service is None:
87
+ service = ""
88
+ if not isinstance(service, str):
89
+ raise ValueError("service must be a string")
90
+ graph_id = graph_id.strip()
91
+ return (service.strip() or graph_id, graph_id)
92
+
93
+ def resolve(self, graph_id: str, service: str = "") -> LangGraphEndpoint:
94
+ """Return a healthy LangGraph endpoint, retaining the previous ``host:port``."""
95
+ service, graph_id = self._endpoint_key(graph_id, service)
96
+ key = (service, graph_id)
97
+ with self._lock:
98
+ self._check_open()
99
+ cached = self._cache.get(key)
100
+ try:
101
+ response = self._consumer.get_healthy_instances(
102
+ GetHealthyInstancesRequest(
103
+ namespace=self.namespace,
104
+ service=service,
105
+ timeout=self.timeout,
106
+ exclude_offline_service=True,
107
+ )
108
+ )
109
+ except SDKError:
110
+ if cached is None:
111
+ raise
112
+ self.logger.warning_throttled(
113
+ ("refresh", service, graph_id),
114
+ "Failed to refresh LangGraph %s; reusing %s:%s",
115
+ service,
116
+ cached.host,
117
+ cached.port,
118
+ )
119
+ return cached
120
+
121
+ instances = [] if response is None else list(response.get_instances())
122
+ instances = [item for item in instances if item.is_healthy() and not item.is_isolated()]
123
+ if cached is not None:
124
+ instances.sort(
125
+ key=lambda item: (
126
+ item.get_host(),
127
+ item.get_port(),
128
+ )
129
+ != (cached.host, cached.port)
130
+ )
131
+ for instance in instances:
132
+ metadata = instance.get_metadata()
133
+ registered_graph = metadata.get(META_GRAPH_ID, "").strip()
134
+ if registered_graph and registered_graph != graph_id:
135
+ continue
136
+ protocol = metadata.get(META_PROTOCOL, "").strip() or instance.get_protocol().strip()
137
+ path = metadata.get(META_PATH, "").strip() or "/"
138
+ if protocol not in ("http", "https") or not path.startswith("/"):
139
+ self.logger.warning_throttled(
140
+ ("invalid-endpoint", service, instance.get_host(), instance.get_port()),
141
+ "Skipping invalid LangGraph endpoint metadata for %s at %s:%s",
142
+ service,
143
+ instance.get_host(),
144
+ instance.get_port(),
145
+ )
146
+ continue
147
+ endpoint = LangGraphEndpoint(
148
+ namespace=self.namespace,
149
+ service=service,
150
+ graph_id=graph_id,
151
+ host=instance.get_host(),
152
+ port=instance.get_port(),
153
+ protocol=protocol,
154
+ path=path,
155
+ )
156
+ parsed = urlsplit(endpoint.url)
157
+ if not parsed.hostname or parsed.port is None:
158
+ continue
159
+ if cached is not None and (cached.host, cached.port) != (
160
+ endpoint.host,
161
+ endpoint.port,
162
+ ):
163
+ self.logger.info(
164
+ "Switched LangGraph service %s/%s from %s:%s to %s:%s",
165
+ self.namespace,
166
+ service,
167
+ cached.host,
168
+ cached.port,
169
+ endpoint.host,
170
+ endpoint.port,
171
+ )
172
+ else:
173
+ self.logger.debug(
174
+ "Selected LangGraph endpoint: service=%s graph=%s host=%s port=%s",
175
+ service,
176
+ graph_id,
177
+ endpoint.host,
178
+ endpoint.port,
179
+ )
180
+ self._cache[key] = endpoint
181
+ return endpoint
182
+ raise LangGraphEndpointNotFoundError(
183
+ f"No healthy LangGraph endpoint for {self.namespace}/{service} "
184
+ f"and graph {graph_id!r}"
185
+ )
186
+
187
+ async def async_resolve(self, graph_id: str, service: str = "") -> LangGraphEndpoint:
188
+ return await await_completion(asyncio.to_thread(self.resolve, graph_id, service))
189
+
190
+ def selected_endpoint(self, graph_id: str, service: str = "") -> LangGraphEndpoint | None:
191
+ with self._lock:
192
+ return self._cache.get(self._endpoint_key(graph_id, service))
193
+
194
+ def invalidate(self, graph_id: str, service: str = ""):
195
+ with self._lock:
196
+ self._cache.pop(self._endpoint_key(graph_id, service), None)
197
+
198
+ def invalidate_all(self):
199
+ with self._lock:
200
+ self._cache.clear()
201
+
202
+ @property
203
+ def consumer(self):
204
+ return self._consumer
205
+
206
+ def close(self):
207
+ with self._lock:
208
+ self._closed = True
209
+ self._cache.clear()
210
+
211
+ async def async_close(self):
212
+ await await_completion(asyncio.to_thread(self.close))
213
+
214
+ def __enter__(self):
215
+ self._check_open()
216
+ return self
217
+
218
+ def __exit__(self, *_):
219
+ self.close()
220
+
221
+ async def __aenter__(self):
222
+ self._check_open()
223
+ return self
224
+
225
+ async def __aexit__(self, *_):
226
+ await self.async_close()