a7d6d88f6f
CI / changes (push) Has been cancelled
CI / cd libs/checkpoint (push) Has been cancelled
CI / cd libs/checkpoint-conformance (push) Has been cancelled
CI / cd libs/checkpoint-postgres (push) Has been cancelled
CI / cd libs/checkpoint-sqlite (push) Has been cancelled
CI / cd libs/cli (push) Has been cancelled
CI / cd libs/prebuilt (push) Has been cancelled
CI / cd libs/sdk-py (push) Has been cancelled
CI / cd libs/langgraph (push) Has been cancelled
CI / Check SDK methods matching (push) Has been cancelled
CI / Check CLI schema hasn't changed #3.13 (push) Has been cancelled
CI / CLI integration test (push) Has been cancelled
CI / sdk-py integration test (push) Has been cancelled
CI / CI Success (push) Has been cancelled
baseline / benchmark (push) Has been cancelled
Deploy Redirects to GitHub Pages / deploy (push) Has been cancelled
252 lines
8.7 KiB
Python
252 lines
8.7 KiB
Python
"""Shared utility functions for async and sync clients."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import os
|
|
import re
|
|
from collections.abc import Mapping
|
|
from datetime import tzinfo
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
from urllib.parse import quote, urlparse
|
|
|
|
import httpx
|
|
|
|
import langgraph_sdk
|
|
from langgraph_sdk.schema import RunCreateMetadata
|
|
|
|
if TYPE_CHECKING:
|
|
from zoneinfo import ZoneInfo
|
|
|
|
RESERVED_HEADERS = ("x-api-key",)
|
|
|
|
NOT_PROVIDED = cast(None, object())
|
|
|
|
|
|
def _get_api_key(api_key: str | None = NOT_PROVIDED) -> str | None:
|
|
"""Get the API key from the environment.
|
|
Precedence:
|
|
1. explicit string argument
|
|
2. LANGGRAPH_API_KEY (if api_key not provided)
|
|
3. LANGSMITH_API_KEY (if api_key not provided)
|
|
4. LANGCHAIN_API_KEY (if api_key not provided)
|
|
|
|
Args:
|
|
api_key: The API key to use. Can be:
|
|
- A string: use this exact API key
|
|
- None: explicitly skip loading from environment
|
|
- NOT_PROVIDED (default): auto-load from environment variables
|
|
"""
|
|
if isinstance(api_key, str):
|
|
return api_key
|
|
if api_key is NOT_PROVIDED:
|
|
# api_key is not explicitly provided, try to load from environment
|
|
for prefix in ["LANGGRAPH", "LANGSMITH", "LANGCHAIN"]:
|
|
if env := os.getenv(f"{prefix}_API_KEY"):
|
|
return env.strip().strip('"').strip("'")
|
|
# api_key is explicitly None, don't load from environment
|
|
return None
|
|
|
|
|
|
def _get_headers(
|
|
api_key: str | None,
|
|
custom_headers: Mapping[str, str] | None,
|
|
) -> dict[str, str]:
|
|
"""Combine api_key and custom user-provided headers."""
|
|
custom_headers = custom_headers or {}
|
|
for header in RESERVED_HEADERS:
|
|
if header in custom_headers:
|
|
raise ValueError(f"Cannot set reserved header '{header}'")
|
|
|
|
headers = {
|
|
"User-Agent": f"langgraph-sdk-py/{langgraph_sdk.__version__}",
|
|
**custom_headers,
|
|
}
|
|
resolved_api_key = _get_api_key(api_key)
|
|
if resolved_api_key:
|
|
headers["x-api-key"] = resolved_api_key
|
|
|
|
return headers
|
|
|
|
|
|
def _orjson_default(obj: Any) -> Any:
|
|
is_class = isinstance(obj, type)
|
|
if hasattr(obj, "model_dump") and callable(obj.model_dump):
|
|
if is_class:
|
|
raise TypeError(
|
|
f"Cannot JSON-serialize type object: {obj!r}. Did you mean to pass an instance of the object instead?"
|
|
f"\nReceived type: {obj!r}"
|
|
)
|
|
return obj.model_dump()
|
|
elif hasattr(obj, "dict") and callable(obj.dict):
|
|
if is_class:
|
|
raise TypeError(
|
|
f"Cannot JSON-serialize type object: {obj!r}. Did you mean to pass an instance of the object instead?"
|
|
f"\nReceived type: {obj!r}"
|
|
)
|
|
return obj.dict()
|
|
elif isinstance(obj, (set, frozenset)):
|
|
return list(obj)
|
|
else:
|
|
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
|
|
|
|
|
|
# Compiled regex pattern for extracting run metadata from Content-Location header
|
|
_RUN_METADATA_PATTERN = re.compile(
|
|
r"(\/threads\/(?P<thread_id>.+))?\/runs\/(?P<run_id>.+)"
|
|
)
|
|
|
|
|
|
def _get_run_metadata_from_response(
|
|
response: httpx.Response,
|
|
) -> RunCreateMetadata | None:
|
|
"""Extract run metadata from the response headers."""
|
|
if (content_location := response.headers.get("Content-Location")) and (
|
|
match := _RUN_METADATA_PATTERN.search(content_location)
|
|
):
|
|
return RunCreateMetadata(
|
|
run_id=match.group("run_id"),
|
|
thread_id=match.group("thread_id") or None,
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def _sse_to_v2_dict(event: str, data: Any) -> dict[str, Any] | None:
|
|
"""Convert an SSE event+data pair into a v2 stream part dict.
|
|
|
|
Returns None for ``end`` events (signals end of stream).
|
|
"""
|
|
if event == "end":
|
|
return None
|
|
parts = event.split("|")
|
|
event_type = parts[0]
|
|
ns = parts[1:] if len(parts) > 1 else []
|
|
result: dict[str, Any] = {"type": event_type, "ns": ns, "data": data}
|
|
if event_type == "values" and isinstance(data, dict):
|
|
result["interrupts"] = data.pop("__interrupt__", [])
|
|
else:
|
|
result["interrupts"] = []
|
|
return result
|
|
|
|
|
|
def _resolve_timezone(tz: str | tzinfo | ZoneInfo | None) -> str | None:
|
|
"""Convert a timezone argument to an IANA timezone string.
|
|
|
|
Accepts:
|
|
- A string (returned as-is, assumed to be an IANA timezone name)
|
|
- A ``datetime.tzinfo`` instance (e.g. ``zoneinfo.ZoneInfo("America/New_York")``,
|
|
``datetime.timezone.utc``). The ``key`` attribute is used if available,
|
|
otherwise ``tzname(None)`` is used.
|
|
- ``None`` (returned as ``None``)
|
|
"""
|
|
if tz is None or isinstance(tz, str):
|
|
return tz
|
|
if isinstance(tz, tzinfo):
|
|
# ZoneInfo objects have a .key attribute with the IANA name
|
|
key = getattr(tz, "key", None)
|
|
if isinstance(key, str):
|
|
return key
|
|
# Fall back to tzname for fixed-offset timezones like datetime.timezone.utc
|
|
name = tz.tzname(None)
|
|
if name is not None:
|
|
return name
|
|
raise ValueError(
|
|
f"Cannot determine timezone name from {tz!r}. "
|
|
"Use a zoneinfo.ZoneInfo instance or pass a string like 'America/New_York'."
|
|
)
|
|
raise TypeError(
|
|
f"Expected str, datetime.tzinfo, or None for timezone, got {type(tz).__name__}"
|
|
)
|
|
|
|
|
|
def _default_port(scheme: str) -> int:
|
|
return 443 if scheme == "https" else 80
|
|
|
|
|
|
def _validate_reconnect_location(base_url: httpx.URL, location: str) -> str:
|
|
"""Validate that a reconnect Location URL is same-origin as the base URL.
|
|
|
|
Raises ValueError if the Location header points to a different origin
|
|
(scheme + host + port), which would leak credentials to an external server.
|
|
"""
|
|
parsed = urlparse(location)
|
|
# Relative URLs are safe — they resolve against the base
|
|
if not parsed.scheme and not parsed.netloc:
|
|
return location
|
|
# Compare origin components (normalize default ports to avoid mismatches)
|
|
base_scheme = str(base_url.scheme)
|
|
base_origin = (
|
|
base_scheme,
|
|
str(base_url.host),
|
|
base_url.port or _default_port(base_scheme),
|
|
)
|
|
loc_origin = (
|
|
parsed.scheme,
|
|
parsed.hostname or "",
|
|
parsed.port or _default_port(parsed.scheme),
|
|
)
|
|
if base_origin != loc_origin:
|
|
raise ValueError(
|
|
f"Refusing to follow cross-origin reconnect Location: {location!r} "
|
|
f"(origin {loc_origin}) does not match base URL origin {base_origin}"
|
|
)
|
|
return location
|
|
|
|
|
|
def _provided_vals(d: Mapping[str, Any]) -> dict[str, Any]:
|
|
return {k: v for k, v in d.items() if v is not None}
|
|
|
|
|
|
def _quote_path_param(value: Any) -> str:
|
|
"""Encode a value for safe interpolation into a request path segment.
|
|
|
|
Path segments are encoded with ``safe=""`` so that ``/`` and other reserved
|
|
characters are escaped. Standalone dot-segments (``.`` and ``..``) are also
|
|
encoded because some URL-handling stacks (including ``httpx``) collapse
|
|
them client-side as relative-path traversal before transmission. The value
|
|
is coerced to ``str`` so callers can pass ``uuid.UUID`` and similar types
|
|
directly without changing call sites.
|
|
|
|
A properly formed identifier (for example, a standard UUID, which contains
|
|
no dots or reserved characters) round-trips through this function
|
|
unchanged.
|
|
|
|
Raises:
|
|
TypeError: If `value` is `None` or a `bytes`/`bytearray` instance.
|
|
Coercing those would produce misleading paths (e.g. `/threads/None`),
|
|
so surface the caller bug instead.
|
|
"""
|
|
if value is None:
|
|
raise TypeError("path parameter must not be None")
|
|
if isinstance(value, (bytes, bytearray)):
|
|
raise TypeError("path parameter must not be bytes; pass a str or uuid.UUID")
|
|
quoted = quote(str(value), safe="")
|
|
# Bare "." or ".." (or any all-dot string) acts as a relative-path segment
|
|
# that some HTTP stacks (including ``httpx``) collapse client-side before
|
|
# transmission. Encode the dots so the segment becomes opaque to that
|
|
# logic. Mixed values like "agent.v1" are unaffected.
|
|
if quoted and all(c == "." for c in quoted):
|
|
quoted = "%2E" * len(quoted)
|
|
return quoted
|
|
|
|
|
|
_registered_transports: list[httpx.ASGITransport] = []
|
|
|
|
|
|
# Do not move; this is used in the server.
|
|
def configure_loopback_transports(app: Any) -> None:
|
|
for transport in _registered_transports:
|
|
transport.app = app
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def get_asgi_transport() -> type[httpx.ASGITransport]:
|
|
try:
|
|
from langgraph_api import asgi_transport # ty: ignore[unresolved-import]
|
|
|
|
return asgi_transport.ASGITransport
|
|
except ImportError:
|
|
# Older versions of the server
|
|
return httpx.ASGITransport
|