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,464 @@
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
+ """Read-only published and mounted Skill loaders for AgentScope."""
17
+
18
+ import asyncio
19
+ import io
20
+ import re
21
+ import shutil
22
+ import stat
23
+ import tempfile
24
+ import time
25
+ import zipfile
26
+ from collections.abc import Sequence
27
+ from dataclasses import dataclass
28
+ from pathlib import Path, PurePosixPath
29
+ from threading import RLock
30
+
31
+ try:
32
+ import frontmatter
33
+ from agentscope.skill import Skill, SkillLoaderBase
34
+ except ImportError as error:
35
+ raise ImportError(
36
+ "PolarisSkillLoader requires pip install 'polaris-python-ai[agentscope]'."
37
+ ) from error
38
+
39
+ from polaris.api.consumer import ConsumerAPI
40
+ from polaris.api.skill import SkillAPI
41
+ from polaris.pkg.model.error import ServerCode
42
+ from polaris.pkg.model.service import ExtendedMetadata, GetAllInstancesRequest
43
+ from polaris.pkg.model.skill import SkillDownloadRequest, SkillListRequest
44
+
45
+ from polaris_ai._async import await_completion
46
+ from polaris_ai.log import get_ai_logger
47
+
48
+ _MAX_ARCHIVE_BYTES = 64 * 1024 * 1024
49
+ _MAX_ARCHIVE_FILES = 2048
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class PolarisSkillRepositoryInfo:
54
+ """Read-only repository identity exposed to AgentScope applications."""
55
+
56
+ type: str
57
+ location: str
58
+ writeable: bool = False
59
+
60
+
61
+ class PolarisSkillNotFoundError(LookupError):
62
+ """The requested Skill does not exist in the selected Polaris repository."""
63
+
64
+
65
+ class PolarisSkillLoader(SkillLoaderBase):
66
+ """Load published skills, or only skills mounted on ``service_name``.
67
+
68
+ The supplied SDKContext is borrowed. Extracted assets live until ``close``;
69
+ persistent offline backups remain the responsibility of the SDK SkillAPI.
70
+ ``refresh_interval`` is in seconds and request ``timeout`` is in milliseconds.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ sdk_context,
76
+ namespace: str = "default",
77
+ *,
78
+ service_name: str = "",
79
+ version: str = "",
80
+ names: Sequence[str] = (),
81
+ list_limit: int = 50,
82
+ max_skills: int = 100,
83
+ refresh_interval: float = 30.0,
84
+ timeout: int = 0,
85
+ logger=None,
86
+ ):
87
+ if sdk_context is None or sdk_context.is_destroy():
88
+ raise ValueError("An active SDKContext is required")
89
+ if (type(list_limit) is not int or list_limit <= 0
90
+ or type(max_skills) is not int or max_skills <= 0):
91
+ raise ValueError("list_limit and max_skills must be positive integers")
92
+ if not isinstance(refresh_interval, (int, float)) or refresh_interval < 0:
93
+ raise ValueError("refresh_interval must be nonnegative")
94
+ if type(timeout) is not int or timeout < 0:
95
+ raise ValueError("timeout must be a nonnegative integer")
96
+ if isinstance(names, str) or any(not isinstance(n, str) or not n.strip() for n in names):
97
+ raise ValueError("names must be a sequence of nonempty skill names")
98
+ if service_name and names:
99
+ raise ValueError("names cannot be combined with service_name")
100
+ self.namespace = namespace.strip() or "default"
101
+ self.service_name = service_name.strip()
102
+ self.version = version.strip()
103
+ self.names = tuple(n.strip() for n in names)
104
+ self.list_limit = list_limit
105
+ self.max_skills = max_skills
106
+ self.refresh_interval = refresh_interval
107
+ self.timeout = timeout
108
+ self.logger = logger if logger is not None else get_ai_logger("skill")
109
+ self._context = sdk_context
110
+ self._skill_api = SkillAPI(sdk_context)
111
+ self._consumer = ConsumerAPI(sdk_context)
112
+ self._refs: tuple[tuple[str, str], ...] = ()
113
+ self._refreshed_at: float | None = None
114
+ self._cache: dict[tuple[str, str], Skill] = {}
115
+ self._lock = asyncio.Lock()
116
+ self._files_lock = RLock()
117
+ self._directory: tempfile.TemporaryDirectory | None = None
118
+ self._closed = False
119
+
120
+ def _check_open(self):
121
+ if self._closed or self._context.is_destroy():
122
+ raise RuntimeError("Skill loader or SDKContext is closed")
123
+
124
+ @property
125
+ def repository_type(self) -> str:
126
+ return "polaris-mounted" if self.service_name else "polaris"
127
+
128
+ @property
129
+ def source(self) -> str:
130
+ if self.service_name:
131
+ return f"polaris-mounted:{self.namespace}/{self.service_name}"
132
+ return f"polaris:{self.namespace}"
133
+
134
+ @property
135
+ def location(self) -> str:
136
+ if self.service_name:
137
+ return f"namespace:{self.namespace}/{self.service_name}"
138
+ return f"namespace:{self.namespace}"
139
+
140
+ def is_writeable(self) -> bool:
141
+ return False
142
+
143
+ def get_repository_info(self) -> PolarisSkillRepositoryInfo:
144
+ return PolarisSkillRepositoryInfo(
145
+ type=self.repository_type,
146
+ location=self.location,
147
+ writeable=self.is_writeable(),
148
+ )
149
+
150
+ async def list_skills(self) -> list[Skill]:
151
+ """Return native AgentScope Skills; failed downloads are skipped and retried later."""
152
+ async with self._lock:
153
+ self._check_open()
154
+ self.logger.debug(
155
+ "Listing Polaris Skills: namespace=%s mounted=%s",
156
+ self.namespace, bool(self.service_name),
157
+ )
158
+ await self._refresh_refs()
159
+ self._check_open()
160
+ result = []
161
+ for name, version in self._refs:
162
+ try:
163
+ result.append(await self._load_skill(name, version))
164
+ except Exception as error:
165
+ self._check_open()
166
+ self.logger.warning_throttled(
167
+ ("load", name, version),
168
+ "Failed to load skill %s/%s: %s", self.namespace, name, error,
169
+ )
170
+ self.logger.debug(
171
+ "Listed Polaris Skills: namespace=%s requested=%d loaded=%d",
172
+ self.namespace, len(self._refs), len(result),
173
+ )
174
+ return result
175
+
176
+ async def async_get_skill(self, name: str, *, version: str | None = None) -> Skill:
177
+ """Load one skill; mounted loaders only accept names in the current mount table."""
178
+ if not isinstance(name, str) or not name.strip():
179
+ raise ValueError("Skill name is required")
180
+ name = name.strip()
181
+ async with self._lock:
182
+ self._check_open()
183
+ resolved = self.version if version is None else version.strip()
184
+ self.logger.debug(
185
+ "Loading Polaris Skill: namespace=%s name=%s version=%s",
186
+ self.namespace, name, resolved,
187
+ )
188
+ if self.service_name:
189
+ await self._refresh_refs()
190
+ mounts = dict(self._refs)
191
+ if name not in mounts:
192
+ raise LookupError(f"Skill {name!r} is not mounted on {self.service_name!r}")
193
+ resolved = mounts[name]
194
+ return await self._load_skill(name, resolved)
195
+
196
+ async def async_list_skill_names(self) -> list[str]:
197
+ """Refresh repository references and return names without downloading ZIPs."""
198
+ async with self._lock:
199
+ self._check_open()
200
+ await self._refresh_refs()
201
+ self._check_open()
202
+ return [name for name, _ in self._refs]
203
+
204
+ async def async_skill_exists(self, name: str) -> bool:
205
+ """Check whether a Skill is available in the selected repository."""
206
+ if not isinstance(name, str) or not name.strip():
207
+ return False
208
+ name = name.strip()
209
+ if self.service_name:
210
+ async with self._lock:
211
+ self._check_open()
212
+ await self._refresh_refs()
213
+ self._check_open()
214
+ return name in dict(self._refs)
215
+ try:
216
+ await self.async_get_skill(name)
217
+ except Exception:
218
+ return False
219
+ return True
220
+
221
+ async def _refresh_refs(self):
222
+ previous = self._refs
223
+ if self.names:
224
+ refs = tuple((name, self.version) for name in self.names)
225
+ if refs != previous:
226
+ self.logger.info(
227
+ "Configured Polaris Skill references changed: namespace=%s count=%d",
228
+ self.namespace, len(refs),
229
+ )
230
+ self.logger.debug("Configured Polaris Skill references: %s", refs)
231
+ self._refs = refs
232
+ return
233
+ if (self._refreshed_at is not None
234
+ and time.monotonic() - self._refreshed_at < self.refresh_interval
235
+ and (not self.service_name or self._refs)):
236
+ self.logger.debug(
237
+ "Reusing Polaris Skill reference cache: namespace=%s count=%d",
238
+ self.namespace, len(self._refs),
239
+ )
240
+ return
241
+ refs = await self._mounted_refs() if self.service_name else await self._published_refs()
242
+ self._check_open()
243
+ self._refs = tuple(refs)
244
+ self._refreshed_at = time.monotonic()
245
+ if self._refs != previous:
246
+ self.logger.info(
247
+ "Polaris Skill references changed: namespace=%s mounted=%s before=%d after=%d",
248
+ self.namespace, bool(self.service_name), len(previous), len(self._refs),
249
+ )
250
+ self.logger.debug("Polaris Skill references: %s", self._refs)
251
+
252
+ async def _published_refs(self):
253
+ refs = []
254
+ offset = 0
255
+ while len(refs) < self.max_skills:
256
+ response = await self._skill_api.async_list_skills(SkillListRequest(
257
+ namespace=self.namespace, offset=offset, limit=self.list_limit, timeout=self.timeout,
258
+ ))
259
+ if response is None or response.code not in (0, 200000):
260
+ raise RuntimeError("Failed to list skills from Polaris")
261
+ resources = response.resources
262
+ self.logger.debug(
263
+ "Listed Polaris Skill page: namespace=%s offset=%d limit=%d "
264
+ "total=%s returned=%d",
265
+ self.namespace, offset, self.list_limit,
266
+ getattr(response, "total", None), len(resources or ()),
267
+ )
268
+ if not resources:
269
+ break
270
+ for resource in resources:
271
+ if resource is None or not resource.name.strip():
272
+ continue
273
+ active = resource.version_info.active_version if resource.version_info else ""
274
+ refs.append((resource.name, self.version or active or ""))
275
+ if len(refs) >= self.max_skills:
276
+ break
277
+ offset += self.list_limit
278
+ if len(resources) < self.list_limit or offset >= response.total:
279
+ break
280
+ return refs
281
+
282
+ async def _mounted_refs(self):
283
+ response = await self._consumer.async_get_all_instances(GetAllInstancesRequest(
284
+ namespace=self.namespace, service=self.service_name, timeout=self.timeout,
285
+ ))
286
+ refs = {}
287
+ if response is None:
288
+ return []
289
+ for meta in response.get_extended_metadata():
290
+ if meta.type != ExtendedMetadata.SKILL or meta.agent_skill is None:
291
+ continue
292
+ skill = meta.agent_skill
293
+ raw = skill.name if skill.name.strip() else skill.id
294
+ namespace, separator, name = raw.partition(":")
295
+ if not separator or not namespace.strip() or not name.strip():
296
+ self.logger.warning_throttled(
297
+ ("invalid-mount", raw),
298
+ "Invalid mounted skill identity: %r", raw,
299
+ )
300
+ continue
301
+ if namespace.strip() != self.namespace:
302
+ self.logger.warning_throttled(
303
+ ("mount-namespace", raw),
304
+ "Skipping mounted skill %s outside namespace %s", raw, self.namespace,
305
+ )
306
+ continue
307
+ refs.setdefault(name.strip(), skill.version.strip() or self.version)
308
+ return list(refs.items())
309
+
310
+ async def _load_skill(self, name, version):
311
+ self._check_open()
312
+ fixed_key = (name, version) if version else None
313
+ if fixed_key is not None and fixed_key in self._cache:
314
+ self.logger.debug(
315
+ "Using cached Polaris Skill: name=%s version=%s", name, version,
316
+ )
317
+ return self._cache[fixed_key]
318
+ self.logger.debug(
319
+ "Downloading Polaris Skill: namespace=%s name=%s version=%s",
320
+ self.namespace, name, version or "<active>",
321
+ )
322
+ response = await self._skill_api.async_download_skill(SkillDownloadRequest(
323
+ namespace=self.namespace, name=name, version=version, timeout=self.timeout, format="zip",
324
+ ))
325
+ if response is None:
326
+ raise RuntimeError(f"Failed to download skill {name!r}")
327
+ if response.code == ServerCode.NotFoundResource:
328
+ raise PolarisSkillNotFoundError(
329
+ f"Skill {name!r} was not found in {self.namespace!r}"
330
+ )
331
+ if response.code not in (0, ServerCode.ExecuteSuccess):
332
+ detail = f", info={response.info!r}" if response.info else ""
333
+ raise RuntimeError(
334
+ f"Failed to download skill {name!r} (code={response.code}{detail})"
335
+ )
336
+ if not response.data:
337
+ raise RuntimeError(f"Failed to download skill {name!r} (empty ZIP)")
338
+ actual_version = response.version if isinstance(response.version, str) else ""
339
+ actual_version = actual_version.strip()
340
+ self.logger.debug(
341
+ "Downloaded Polaris Skill: name=%s requestedVersion=%s actualVersion=%s bytes=%d",
342
+ name, version or "<active>", actual_version or "<unknown>", len(response.data),
343
+ )
344
+ if fixed_key is not None:
345
+ cache_key = fixed_key
346
+ else:
347
+ cache_key = (name, actual_version) if actual_version else None
348
+ if cache_key is not None and cache_key in self._cache:
349
+ self.logger.debug(
350
+ "Reusing materialized Polaris Skill: name=%s version=%s",
351
+ name, actual_version or version,
352
+ )
353
+ return self._cache[cache_key]
354
+ # Extraction is isolated from the event loop and close cannot race filesystem writes.
355
+ return await await_completion(asyncio.to_thread(
356
+ self._materialize, cache_key, response.data,
357
+ ))
358
+
359
+ def _materialize(self, key, data):
360
+ with self._files_lock:
361
+ self._check_open()
362
+ if key is not None and key in self._cache:
363
+ return self._cache[key]
364
+ if self._directory is None:
365
+ self._directory = tempfile.TemporaryDirectory(prefix="polaris-ai-skills-")
366
+ directory = Path(tempfile.mkdtemp(dir=self._directory.name))
367
+ try:
368
+ root = _extract_archive(data, directory)
369
+ markdown_path = root / "SKILL.md"
370
+ content = frontmatter.loads(markdown_path.read_text(encoding="utf-8-sig"))
371
+ name = content.get("name")
372
+ description = content.get("description")
373
+ if not name or not description:
374
+ raise ValueError("SKILL.md requires name and description front matter")
375
+ skill = Skill(
376
+ name=str(name), description=str(description), dir=str(root),
377
+ markdown=content.content, updated_at=markdown_path.stat().st_mtime,
378
+ )
379
+ if key is not None:
380
+ self._cache[key] = skill
381
+ self.logger.debug(
382
+ "Materialized Polaris Skill: name=%s directory=%s", skill.name, skill.dir,
383
+ )
384
+ return skill
385
+ except BaseException:
386
+ shutil.rmtree(directory)
387
+ raise
388
+
389
+ def close(self):
390
+ """Remove this loader's temporary assets without destroying the borrowed SDKContext."""
391
+ with self._files_lock:
392
+ self._closed = True
393
+ self.logger.debug(
394
+ "Closing Polaris Skill loader: namespace=%s cached=%d",
395
+ self.namespace, len(self._cache),
396
+ )
397
+ self._cache.clear()
398
+ self._refs = ()
399
+ if self._directory is not None:
400
+ self._directory.cleanup()
401
+ self._directory = None
402
+
403
+ async def async_close(self):
404
+ await await_completion(self._close_async())
405
+
406
+ async def _close_async(self):
407
+ async with self._lock:
408
+ await asyncio.to_thread(self.close)
409
+
410
+ def __enter__(self):
411
+ self._check_open()
412
+ return self
413
+
414
+ def __exit__(self, *_):
415
+ self.close()
416
+
417
+ async def __aenter__(self):
418
+ self._check_open()
419
+ return self
420
+
421
+ async def __aexit__(self, *_):
422
+ await self.async_close()
423
+
424
+
425
+ def _extract_archive(data: bytes, directory: Path) -> Path:
426
+ if len(data) > _MAX_ARCHIVE_BYTES:
427
+ raise ValueError("Skill ZIP exceeds the 64 MiB limit")
428
+ with zipfile.ZipFile(io.BytesIO(data)) as archive:
429
+ entries = archive.infolist()
430
+ if len(entries) > _MAX_ARCHIVE_FILES:
431
+ raise ValueError("Skill ZIP exceeds the 2048 entry limit")
432
+ if sum(entry.file_size for entry in entries) > _MAX_ARCHIVE_BYTES:
433
+ raise ValueError("Expanded Skill ZIP exceeds the 64 MiB limit")
434
+ paths = []
435
+ seen = set()
436
+ for entry in entries:
437
+ raw = entry.orig_filename
438
+ path = PurePosixPath(raw)
439
+ mode = stat.S_IFMT(entry.external_attr >> 16)
440
+ if ("\x00" in raw or "\\" in raw or path.is_absolute() or ".." in path.parts
441
+ or re.match(r"^[A-Za-z]:", raw) or not path.parts
442
+ or mode not in (0, stat.S_IFREG, stat.S_IFDIR)):
443
+ raise ValueError(f"Unsafe Skill ZIP entry: {raw!r}")
444
+ canonical = path.as_posix().casefold()
445
+ if canonical in seen:
446
+ raise ValueError(f"Duplicate Skill ZIP entry: {raw!r}")
447
+ seen.add(canonical)
448
+ paths.append((entry, path))
449
+ definitions = [path for entry, path in paths
450
+ if not entry.is_dir() and path.name == "SKILL.md"]
451
+ if len(definitions) != 1 or len(definitions[0].parts) > 2:
452
+ raise ValueError("Skill ZIP must contain one root or top-level directory SKILL.md")
453
+ root = definitions[0].parent
454
+ if root.parts and any(not path.is_relative_to(root) for _, path in paths):
455
+ raise ValueError("All Skill ZIP assets must be under the same skill root")
456
+ for entry, path in paths:
457
+ target = directory.joinpath(*path.parts)
458
+ if entry.is_dir():
459
+ target.mkdir(parents=True, exist_ok=True)
460
+ else:
461
+ target.parent.mkdir(parents=True, exist_ok=True)
462
+ with archive.open(entry) as source, target.open("xb") as output:
463
+ shutil.copyfileobj(source, output)
464
+ return directory.joinpath(*root.parts)
@@ -0,0 +1,50 @@
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
+ """Polaris registration, discovery, and RemoteGraph routing for LangGraph."""
17
+
18
+ from polaris_ai._version import __version__
19
+ from .config import (
20
+ PolarisLangGraphConfig,
21
+ PolarisLangGraphDiscoveryConfig,
22
+ PolarisLangGraphRegistryConfig,
23
+ )
24
+ from .context import PolarisLangGraphContext
25
+ from .registry import (
26
+ LangGraphTransport,
27
+ PolarisLangGraphRegistrar,
28
+ RegisteredInstance,
29
+ )
30
+ from .remote import PolarisRemoteGraph
31
+ from .resolver import (
32
+ LangGraphEndpoint,
33
+ LangGraphEndpointNotFoundError,
34
+ PolarisLangGraphResolver,
35
+ )
36
+
37
+ __all__ = [
38
+ "LangGraphEndpoint",
39
+ "LangGraphEndpointNotFoundError",
40
+ "LangGraphTransport",
41
+ "PolarisLangGraphConfig",
42
+ "PolarisLangGraphContext",
43
+ "PolarisLangGraphDiscoveryConfig",
44
+ "PolarisLangGraphRegistrar",
45
+ "PolarisLangGraphRegistryConfig",
46
+ "PolarisLangGraphResolver",
47
+ "PolarisRemoteGraph",
48
+ "RegisteredInstance",
49
+ "__version__",
50
+ ]
@@ -0,0 +1,160 @@
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
+ """Application options for Polaris-managed LangGraph deployments."""
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from dataclasses import dataclass, field
22
+
23
+
24
+ def _bool_env(value: str, *, default: bool) -> bool:
25
+ lowered = value.strip().lower()
26
+ if lowered in ("1", "true", "yes", "on"):
27
+ return True
28
+ if lowered in ("0", "false", "no", "off"):
29
+ return False
30
+ if not lowered:
31
+ return default
32
+ raise ValueError(f"Invalid boolean environment value: {value!r}")
33
+
34
+
35
+ def _list_env(value: str) -> tuple[str, ...]:
36
+ return tuple(item.strip() for item in value.split(",") if item.strip())
37
+
38
+
39
+ def _int_env(value: str, name: str, default: int) -> int:
40
+ if not value:
41
+ return default
42
+ try:
43
+ return int(value)
44
+ except ValueError as error:
45
+ raise ValueError(f"{name} must be an integer") from error
46
+
47
+
48
+ def _headers_env(value: str) -> tuple[tuple[str, str], ...]:
49
+ headers = []
50
+ for item in _list_env(value):
51
+ name, separator, header_value = item.partition("=")
52
+ if not separator or not name.strip():
53
+ raise ValueError("POLARIS_AI_LANGGRAPH_HEADERS items must be name=value")
54
+ headers.append((name.strip(), header_value.strip()))
55
+ return tuple(headers)
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class PolarisLangGraphRegistryConfig:
60
+ enabled: bool = True
61
+ ttl: int = 5
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class PolarisLangGraphDiscoveryConfig:
66
+ enabled: bool = True
67
+ timeout: int = 0
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class PolarisLangGraphConfig:
72
+ """Code-first settings for registration, discovery, and RemoteGraph calls."""
73
+
74
+ enabled: bool = True
75
+ namespace: str = "default"
76
+ addresses: tuple[str, ...] = ("127.0.0.1:8091",)
77
+ connector_token: str = ""
78
+ service_token: str = ""
79
+ graph_id: str = ""
80
+ service: str = ""
81
+ path: str = "/"
82
+ protocol: str = "http"
83
+ api_key: str = ""
84
+ headers: tuple[tuple[str, str], ...] = ()
85
+ caller_service: str = ""
86
+ labels: str = ""
87
+ registry: PolarisLangGraphRegistryConfig = field(
88
+ default_factory=PolarisLangGraphRegistryConfig,
89
+ )
90
+ discovery: PolarisLangGraphDiscoveryConfig = field(
91
+ default_factory=PolarisLangGraphDiscoveryConfig,
92
+ )
93
+
94
+ @classmethod
95
+ def from_env(
96
+ cls,
97
+ environ: dict[str, str] | None = None,
98
+ *,
99
+ graph_id: str = "",
100
+ service: str = "",
101
+ ) -> "PolarisLangGraphConfig":
102
+ """Load LangGraph options with ``POLARIS_AI_*`` precedence."""
103
+ env = os.environ if environ is None else environ
104
+
105
+ def value(name: str, fallback: str = "") -> str:
106
+ return env.get(name, env.get(fallback, "")).strip()
107
+
108
+ configured_graph = value("POLARIS_AI_LANGGRAPH_GRAPH_ID") or graph_id.strip()
109
+ configured_service = value("POLARIS_AI_LANGGRAPH_SERVICE") or service.strip()
110
+ addresses = _list_env(
111
+ value("POLARIS_AI_LANGGRAPH_ADDRESS", "POLARIS_SERVER_ADDRESS")
112
+ ) or _list_env(
113
+ value("POLARIS_AI_LANGGRAPH_ADDRESSES", "POLARIS_SERVER_ADDRESSES")
114
+ ) or _list_env(value("POLARIS_AI_ADDRESS", "POLARIS_SERVER_ADDRESS"))
115
+ return cls(
116
+ enabled=_bool_env(env.get("POLARIS_AI_LANGGRAPH_ENABLED", ""), default=True),
117
+ namespace=(
118
+ value("POLARIS_AI_LANGGRAPH_NAMESPACE", "POLARIS_NAMESPACE")
119
+ or "default"
120
+ ),
121
+ addresses=addresses or ("127.0.0.1:8091",),
122
+ connector_token=value(
123
+ "POLARIS_AI_LANGGRAPH_CONNECTOR_TOKEN",
124
+ "POLARIS_TOKEN",
125
+ ),
126
+ service_token=value(
127
+ "POLARIS_AI_LANGGRAPH_SERVICE_TOKEN",
128
+ "POLARIS_SERVICE_TOKEN",
129
+ ),
130
+ graph_id=configured_graph,
131
+ service=configured_service or configured_graph,
132
+ path=value("POLARIS_AI_LANGGRAPH_PATH") or "/",
133
+ protocol=value("POLARIS_AI_LANGGRAPH_PROTOCOL") or "http",
134
+ api_key=value("POLARIS_AI_LANGGRAPH_API_KEY", "LANGGRAPH_API_KEY"),
135
+ headers=_headers_env(value("POLARIS_AI_LANGGRAPH_HEADERS")),
136
+ caller_service=value("POLARIS_AI_LANGGRAPH_CALLER_SERVICE"),
137
+ labels=value("POLARIS_AI_LANGGRAPH_LABELS"),
138
+ registry=PolarisLangGraphRegistryConfig(
139
+ enabled=_bool_env(
140
+ env.get("POLARIS_AI_LANGGRAPH_REGISTRY_ENABLED", ""),
141
+ default=True,
142
+ ),
143
+ ttl=_int_env(
144
+ value("POLARIS_AI_LANGGRAPH_TTL"),
145
+ "POLARIS_AI_LANGGRAPH_TTL",
146
+ 5,
147
+ ),
148
+ ),
149
+ discovery=PolarisLangGraphDiscoveryConfig(
150
+ enabled=_bool_env(
151
+ env.get("POLARIS_AI_LANGGRAPH_DISCOVERY_ENABLED", ""),
152
+ default=True,
153
+ ),
154
+ timeout=_int_env(
155
+ value("POLARIS_AI_LANGGRAPH_DISCOVERY_TIMEOUT"),
156
+ "POLARIS_AI_LANGGRAPH_DISCOVERY_TIMEOUT",
157
+ 0,
158
+ ),
159
+ ),
160
+ )