langgraph-api 0.4.1__py3-none-any.whl → 0.7.3__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.
- langgraph_api/__init__.py +1 -1
- langgraph_api/api/__init__.py +111 -51
- langgraph_api/api/a2a.py +1610 -0
- langgraph_api/api/assistants.py +212 -89
- langgraph_api/api/mcp.py +3 -3
- langgraph_api/api/meta.py +52 -28
- langgraph_api/api/openapi.py +27 -17
- langgraph_api/api/profile.py +108 -0
- langgraph_api/api/runs.py +342 -195
- langgraph_api/api/store.py +19 -2
- langgraph_api/api/threads.py +209 -27
- langgraph_api/asgi_transport.py +14 -9
- langgraph_api/asyncio.py +14 -4
- langgraph_api/auth/custom.py +52 -37
- langgraph_api/auth/langsmith/backend.py +4 -3
- langgraph_api/auth/langsmith/client.py +13 -8
- langgraph_api/cli.py +230 -133
- langgraph_api/command.py +5 -3
- langgraph_api/config/__init__.py +532 -0
- langgraph_api/config/_parse.py +58 -0
- langgraph_api/config/schemas.py +431 -0
- langgraph_api/cron_scheduler.py +17 -1
- langgraph_api/encryption/__init__.py +15 -0
- langgraph_api/encryption/aes_json.py +158 -0
- langgraph_api/encryption/context.py +35 -0
- langgraph_api/encryption/custom.py +280 -0
- langgraph_api/encryption/middleware.py +632 -0
- langgraph_api/encryption/shared.py +63 -0
- langgraph_api/errors.py +12 -1
- langgraph_api/executor_entrypoint.py +11 -6
- langgraph_api/feature_flags.py +29 -0
- langgraph_api/graph.py +176 -76
- langgraph_api/grpc/client.py +313 -0
- langgraph_api/grpc/config_conversion.py +231 -0
- langgraph_api/grpc/generated/__init__.py +29 -0
- langgraph_api/grpc/generated/checkpointer_pb2.py +63 -0
- langgraph_api/grpc/generated/checkpointer_pb2.pyi +99 -0
- langgraph_api/grpc/generated/checkpointer_pb2_grpc.py +329 -0
- langgraph_api/grpc/generated/core_api_pb2.py +216 -0
- langgraph_api/grpc/generated/core_api_pb2.pyi +905 -0
- langgraph_api/grpc/generated/core_api_pb2_grpc.py +1621 -0
- langgraph_api/grpc/generated/engine_common_pb2.py +219 -0
- langgraph_api/grpc/generated/engine_common_pb2.pyi +722 -0
- langgraph_api/grpc/generated/engine_common_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2.pyi +12 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_durability_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_durability_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_durability_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_run_status_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_run_status_pb2.pyi +22 -0
- langgraph_api/grpc/generated/enum_run_status_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2.pyi +28 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/errors_pb2.py +39 -0
- langgraph_api/grpc/generated/errors_pb2.pyi +21 -0
- langgraph_api/grpc/generated/errors_pb2_grpc.py +24 -0
- langgraph_api/grpc/ops/__init__.py +370 -0
- langgraph_api/grpc/ops/assistants.py +424 -0
- langgraph_api/grpc/ops/runs.py +792 -0
- langgraph_api/grpc/ops/threads.py +1013 -0
- langgraph_api/http.py +16 -5
- langgraph_api/http_metrics.py +15 -35
- langgraph_api/http_metrics_utils.py +38 -0
- langgraph_api/js/build.mts +1 -1
- langgraph_api/js/client.http.mts +13 -7
- langgraph_api/js/client.mts +2 -5
- langgraph_api/js/package.json +29 -28
- langgraph_api/js/remote.py +56 -30
- langgraph_api/js/src/graph.mts +20 -0
- langgraph_api/js/sse.py +2 -2
- langgraph_api/js/ui.py +1 -1
- langgraph_api/js/yarn.lock +1204 -1006
- langgraph_api/logging.py +29 -2
- langgraph_api/metadata.py +99 -28
- langgraph_api/middleware/http_logger.py +7 -2
- langgraph_api/middleware/private_network.py +7 -7
- langgraph_api/models/run.py +54 -93
- langgraph_api/otel_context.py +205 -0
- langgraph_api/patch.py +5 -3
- langgraph_api/queue_entrypoint.py +154 -65
- langgraph_api/route.py +47 -5
- langgraph_api/schema.py +88 -10
- langgraph_api/self_hosted_logs.py +124 -0
- langgraph_api/self_hosted_metrics.py +450 -0
- langgraph_api/serde.py +79 -37
- langgraph_api/server.py +138 -60
- langgraph_api/state.py +4 -3
- langgraph_api/store.py +25 -16
- langgraph_api/stream.py +80 -29
- langgraph_api/thread_ttl.py +31 -13
- langgraph_api/timing/__init__.py +25 -0
- langgraph_api/timing/profiler.py +200 -0
- langgraph_api/timing/timer.py +318 -0
- langgraph_api/utils/__init__.py +53 -8
- langgraph_api/utils/cache.py +47 -10
- langgraph_api/utils/config.py +2 -1
- langgraph_api/utils/errors.py +77 -0
- langgraph_api/utils/future.py +10 -6
- langgraph_api/utils/headers.py +76 -2
- langgraph_api/utils/retriable_client.py +74 -0
- langgraph_api/utils/stream_codec.py +315 -0
- langgraph_api/utils/uuids.py +29 -62
- langgraph_api/validation.py +9 -0
- langgraph_api/webhook.py +120 -6
- langgraph_api/worker.py +55 -24
- {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/METADATA +16 -8
- langgraph_api-0.7.3.dist-info/RECORD +168 -0
- {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/WHEEL +1 -1
- langgraph_runtime/__init__.py +1 -0
- langgraph_runtime/routes.py +11 -0
- logging.json +1 -3
- openapi.json +839 -478
- langgraph_api/config.py +0 -387
- langgraph_api/js/isolate-0x130008000-46649-46649-v8.log +0 -4430
- langgraph_api/js/isolate-0x138008000-44681-44681-v8.log +0 -4430
- langgraph_api/js/package-lock.json +0 -3308
- langgraph_api-0.4.1.dist-info/RECORD +0 -107
- /langgraph_api/{utils.py → grpc/__init__.py} +0 -0
- {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/entry_points.txt +0 -0
- {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/licenses/LICENSE +0 -0
langgraph_api/utils/__init__.py
CHANGED
|
@@ -7,6 +7,7 @@ from datetime import datetime
|
|
|
7
7
|
from typing import Any, Protocol, TypeAlias, TypeVar, cast
|
|
8
8
|
|
|
9
9
|
import structlog
|
|
10
|
+
from langchain_core.runnables import RunnableConfig
|
|
10
11
|
from langgraph_sdk import Auth
|
|
11
12
|
from starlette.authentication import AuthCredentials, BaseUser
|
|
12
13
|
from starlette.exceptions import HTTPException
|
|
@@ -36,7 +37,7 @@ async def with_user(
|
|
|
36
37
|
if current is None:
|
|
37
38
|
return
|
|
38
39
|
set_auth_ctx(
|
|
39
|
-
cast(BaseUser, current.user), AuthCredentials(scopes=current.permissions)
|
|
40
|
+
cast("BaseUser", current.user), AuthCredentials(scopes=current.permissions)
|
|
40
41
|
)
|
|
41
42
|
|
|
42
43
|
|
|
@@ -60,6 +61,42 @@ def get_auth_ctx() -> Auth.types.BaseAuthContext | None:
|
|
|
60
61
|
return AuthContext.get()
|
|
61
62
|
|
|
62
63
|
|
|
64
|
+
def get_user_id(user: BaseUser | None) -> str | None:
|
|
65
|
+
if user is None:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
return user.identity
|
|
69
|
+
except NotImplementedError:
|
|
70
|
+
try:
|
|
71
|
+
return user.display_name
|
|
72
|
+
except NotImplementedError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def merge_auth(
|
|
77
|
+
config: RunnableConfig,
|
|
78
|
+
ctx: Auth.types.BaseAuthContext | None = None,
|
|
79
|
+
) -> RunnableConfig:
|
|
80
|
+
"""Inject auth context into config's configurable dict.
|
|
81
|
+
|
|
82
|
+
If ctx is not provided, attempts to get it from the current context.
|
|
83
|
+
"""
|
|
84
|
+
if ctx is None:
|
|
85
|
+
ctx = get_auth_ctx()
|
|
86
|
+
if ctx is None:
|
|
87
|
+
return config
|
|
88
|
+
|
|
89
|
+
configurable = config.setdefault("configurable", {})
|
|
90
|
+
return config | {
|
|
91
|
+
"configurable": configurable
|
|
92
|
+
| {
|
|
93
|
+
"langgraph_auth_user": cast("BaseUser | None", ctx.user),
|
|
94
|
+
"langgraph_auth_user_id": get_user_id(cast("BaseUser | None", ctx.user)),
|
|
95
|
+
"langgraph_auth_permissions": ctx.permissions,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
63
100
|
class AsyncCursorProto(Protocol):
|
|
64
101
|
async def fetchone(self) -> Row: ...
|
|
65
102
|
|
|
@@ -139,9 +176,17 @@ class SchemaGenerator(BaseSchemaGenerator):
|
|
|
139
176
|
for endpoint in endpoints_info:
|
|
140
177
|
try:
|
|
141
178
|
parsed = self.parse_docstring(endpoint.func)
|
|
142
|
-
except
|
|
143
|
-
|
|
144
|
-
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
docstring = getattr(endpoint.func, "__doc__", None) or ""
|
|
181
|
+
logger.warning(
|
|
182
|
+
"Unable to parse docstring from OpenAPI schema for route %s (%s): %s\n\nUsing as description",
|
|
183
|
+
endpoint.path,
|
|
184
|
+
endpoint.func.__qualname__,
|
|
185
|
+
exc,
|
|
186
|
+
exc_info=exc,
|
|
187
|
+
docstring=docstring,
|
|
188
|
+
)
|
|
189
|
+
parsed = {"description": docstring}
|
|
145
190
|
|
|
146
191
|
if endpoint.path not in schema["paths"]:
|
|
147
192
|
schema["paths"][endpoint.path] = {}
|
|
@@ -186,14 +231,14 @@ def validate_select_columns(
|
|
|
186
231
|
|
|
187
232
|
|
|
188
233
|
__all__ = [
|
|
234
|
+
"AsyncConnectionProto",
|
|
189
235
|
"AsyncCursorProto",
|
|
190
236
|
"AsyncPipelineProto",
|
|
191
|
-
"AsyncConnectionProto",
|
|
192
|
-
"fetchone",
|
|
193
|
-
"validate_uuid",
|
|
194
|
-
"next_cron_date",
|
|
195
237
|
"SchemaGenerator",
|
|
238
|
+
"fetchone",
|
|
196
239
|
"get_pagination_headers",
|
|
240
|
+
"next_cron_date",
|
|
197
241
|
"uuid7",
|
|
198
242
|
"validate_select_columns",
|
|
243
|
+
"validate_uuid",
|
|
199
244
|
]
|
langgraph_api/utils/cache.py
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import time
|
|
3
3
|
from collections import OrderedDict
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
4
5
|
from typing import Generic, TypeVar
|
|
5
6
|
|
|
6
7
|
T = TypeVar("T")
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
class LRUCache(Generic[T]):
|
|
10
|
-
"""LRU cache with TTL support."""
|
|
11
|
+
"""LRU cache with TTL and proactive refresh support."""
|
|
11
12
|
|
|
12
|
-
def __init__(
|
|
13
|
-
self
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
max_size: int = 1000,
|
|
16
|
+
ttl: float = 60,
|
|
17
|
+
refresh_window: float = 30,
|
|
18
|
+
refresh_callback: Callable[[str], Awaitable[T | None]] | None = None,
|
|
19
|
+
):
|
|
20
|
+
self._cache: OrderedDict[str, tuple[T, float, bool]] = OrderedDict()
|
|
14
21
|
self._max_size = max_size if max_size > 0 else 1000
|
|
15
22
|
self._ttl = ttl
|
|
23
|
+
self._refresh_window = refresh_window if refresh_window > 0 else 30
|
|
24
|
+
self._refresh_callback = refresh_callback
|
|
16
25
|
|
|
17
26
|
def _get_time(self) -> float:
|
|
18
27
|
"""Get current time, using loop.time() if available for better performance."""
|
|
@@ -21,17 +30,45 @@ class LRUCache(Generic[T]):
|
|
|
21
30
|
except RuntimeError:
|
|
22
31
|
return time.monotonic()
|
|
23
32
|
|
|
24
|
-
def get(self, key: str) -> T | None:
|
|
25
|
-
"""Get item from cache,
|
|
33
|
+
async def get(self, key: str) -> T | None:
|
|
34
|
+
"""Get item from cache, attempting refresh if within refresh window."""
|
|
26
35
|
if key not in self._cache:
|
|
27
36
|
return None
|
|
28
37
|
|
|
29
|
-
value, timestamp = self._cache[key]
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
value, timestamp, is_refreshing = self._cache[key]
|
|
39
|
+
current_time = self._get_time()
|
|
40
|
+
time_until_expiry = self._ttl - (current_time - timestamp)
|
|
41
|
+
|
|
42
|
+
# Check if expired
|
|
43
|
+
if time_until_expiry <= 0:
|
|
32
44
|
del self._cache[key]
|
|
33
45
|
return None
|
|
34
46
|
|
|
47
|
+
# Check if we should attempt refresh (within refresh window and not already refreshing)
|
|
48
|
+
if (
|
|
49
|
+
time_until_expiry <= self._refresh_window
|
|
50
|
+
and not is_refreshing
|
|
51
|
+
and self._refresh_callback
|
|
52
|
+
):
|
|
53
|
+
# Mark as refreshing to prevent multiple simultaneous refresh attempts
|
|
54
|
+
self._cache[key] = (value, timestamp, True)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
# Attempt refresh
|
|
58
|
+
refreshed_value = await self._refresh_callback(key)
|
|
59
|
+
if refreshed_value is not None:
|
|
60
|
+
# Refresh successful, update cache with new value
|
|
61
|
+
self._cache[key] = (refreshed_value, current_time, False)
|
|
62
|
+
# Move to end (most recently used)
|
|
63
|
+
self._cache.move_to_end(key)
|
|
64
|
+
return refreshed_value
|
|
65
|
+
else:
|
|
66
|
+
# Refresh failed, fallback to cached value
|
|
67
|
+
self._cache[key] = (value, timestamp, False)
|
|
68
|
+
except Exception:
|
|
69
|
+
# Refresh failed with exception, fallback to cached value
|
|
70
|
+
self._cache[key] = (value, timestamp, False)
|
|
71
|
+
|
|
35
72
|
# Move to end (most recently used)
|
|
36
73
|
self._cache.move_to_end(key)
|
|
37
74
|
return value
|
|
@@ -46,8 +83,8 @@ class LRUCache(Generic[T]):
|
|
|
46
83
|
while len(self._cache) >= self._max_size:
|
|
47
84
|
self._cache.popitem(last=False) # Remove oldest (FIFO)
|
|
48
85
|
|
|
49
|
-
# Add new entry
|
|
50
|
-
self._cache[key] = (value, self._get_time())
|
|
86
|
+
# Add new entry (not refreshing initially)
|
|
87
|
+
self._cache[key] = (value, self._get_time(), False)
|
|
51
88
|
|
|
52
89
|
def size(self) -> int:
|
|
53
90
|
"""Return current cache size."""
|
langgraph_api/utils/config.py
CHANGED
|
@@ -4,7 +4,6 @@ import asyncio
|
|
|
4
4
|
import functools
|
|
5
5
|
import typing
|
|
6
6
|
from collections import ChainMap
|
|
7
|
-
from concurrent.futures import Executor
|
|
8
7
|
from contextvars import copy_context
|
|
9
8
|
from os import getenv
|
|
10
9
|
from typing import Any, ParamSpec, TypeVar
|
|
@@ -13,6 +12,8 @@ from langgraph.constants import CONF
|
|
|
13
12
|
from typing_extensions import TypedDict
|
|
14
13
|
|
|
15
14
|
if typing.TYPE_CHECKING:
|
|
15
|
+
from concurrent.futures import Executor
|
|
16
|
+
|
|
16
17
|
from langchain_core.runnables import RunnableConfig
|
|
17
18
|
|
|
18
19
|
try:
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import traceback
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from langgraph_api.graph import GraphSpec
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GraphLoadError(RuntimeError):
|
|
11
|
+
"""Raised when a user provided graph fails to load."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, spec: GraphSpec, cause: Exception):
|
|
14
|
+
self.spec = spec
|
|
15
|
+
self.cause = cause
|
|
16
|
+
self.location = spec.module or spec.path or "<unknown>"
|
|
17
|
+
self.notes = tuple(getattr(cause, "__notes__", ()) or ())
|
|
18
|
+
self._traceback = traceback.TracebackException.from_exception(
|
|
19
|
+
cause, capture_locals=False
|
|
20
|
+
)
|
|
21
|
+
self._exception_only = list(self._traceback.format_exception_only())
|
|
22
|
+
message = f"Failed to load graph '{spec.id}' from {self.location}: {cause}"
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def hint(self) -> str | None:
|
|
27
|
+
if isinstance(self.cause, ImportError):
|
|
28
|
+
return "Check that your project dependencies are installed and imports are correct."
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
def log_fields(self) -> dict[str, Any]:
|
|
32
|
+
fields: dict[str, Any] = {
|
|
33
|
+
"graph_id": self.spec.id,
|
|
34
|
+
"location": self.location,
|
|
35
|
+
"error_type": type(self.cause).__name__,
|
|
36
|
+
"error_message": self.cause_message,
|
|
37
|
+
"error_boundary": "user_graph",
|
|
38
|
+
"summary": self.summary,
|
|
39
|
+
}
|
|
40
|
+
if self.hint:
|
|
41
|
+
fields["hint"] = self.hint
|
|
42
|
+
if self.notes:
|
|
43
|
+
fields["notes"] = "\n".join(self.notes)
|
|
44
|
+
fields["user_traceback"] = self.user_traceback()
|
|
45
|
+
return fields
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def cause_message(self) -> str:
|
|
49
|
+
if self._exception_only:
|
|
50
|
+
return self._exception_only[0].strip()
|
|
51
|
+
return str(self.cause)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def summary(self) -> str:
|
|
55
|
+
return f"{type(self.cause).__name__}: {self.cause_message}"
|
|
56
|
+
|
|
57
|
+
def user_traceback(self) -> str:
|
|
58
|
+
"""Return the full traceback without filtering."""
|
|
59
|
+
return "".join(self._traceback.format())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class HealthServerStartupError(RuntimeError):
|
|
63
|
+
def __init__(self, host: str, port: int, cause: BaseException):
|
|
64
|
+
self.host = host
|
|
65
|
+
self.port = port
|
|
66
|
+
self.cause = cause
|
|
67
|
+
port_desc = (
|
|
68
|
+
f"{host}:{port}" if host not in {"0.0.0.0", "::"} else f"port {port}"
|
|
69
|
+
)
|
|
70
|
+
if isinstance(cause, OSError) and cause.errno in {48, 98}:
|
|
71
|
+
message = (
|
|
72
|
+
f"Health/metrics server could not bind to {port_desc}: "
|
|
73
|
+
"address already in use. Stop the other process or set PORT to a free port."
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
message = f"Health/metrics server failed to start on {port_desc}: {cause}"
|
|
77
|
+
super().__init__(message)
|
langgraph_api/utils/future.py
CHANGED
|
@@ -2,12 +2,14 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import concurrent.futures
|
|
5
|
-
import contextvars
|
|
6
5
|
import inspect
|
|
7
6
|
import sys
|
|
8
7
|
import types
|
|
9
|
-
from
|
|
10
|
-
|
|
8
|
+
from typing import TYPE_CHECKING, TypeVar, cast
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import contextvars
|
|
12
|
+
from collections.abc import Awaitable, Coroutine, Generator
|
|
11
13
|
|
|
12
14
|
T = TypeVar("T")
|
|
13
15
|
AnyFuture = asyncio.Future | concurrent.futures.Future
|
|
@@ -45,7 +47,8 @@ def _set_concurrent_future_state(
|
|
|
45
47
|
source: AnyFuture,
|
|
46
48
|
) -> None:
|
|
47
49
|
"""Copy state from a future to a concurrent.futures.Future."""
|
|
48
|
-
|
|
50
|
+
if not source.done():
|
|
51
|
+
raise ValueError("Future is not done")
|
|
49
52
|
if source.cancelled():
|
|
50
53
|
concurrent.cancel()
|
|
51
54
|
if not concurrent.set_running_or_notify_cancel():
|
|
@@ -65,7 +68,8 @@ def _copy_future_state(source: AnyFuture, dest: asyncio.Future) -> None:
|
|
|
65
68
|
"""
|
|
66
69
|
if dest.done():
|
|
67
70
|
return
|
|
68
|
-
|
|
71
|
+
if not source.done():
|
|
72
|
+
raise ValueError("Future is not done")
|
|
69
73
|
if dest.cancelled():
|
|
70
74
|
return
|
|
71
75
|
if source.cancelled():
|
|
@@ -152,7 +156,7 @@ def _ensure_future(
|
|
|
152
156
|
if not asyncio.iscoroutine(coro_or_future):
|
|
153
157
|
if inspect.isawaitable(coro_or_future):
|
|
154
158
|
coro_or_future = cast(
|
|
155
|
-
Coroutine[None, None, T], _wrap_awaitable(coro_or_future)
|
|
159
|
+
"Coroutine[None, None, T]", _wrap_awaitable(coro_or_future)
|
|
156
160
|
)
|
|
157
161
|
called_wrap_awaitable = True
|
|
158
162
|
else:
|
langgraph_api/utils/headers.py
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
import functools
|
|
4
4
|
import re
|
|
5
|
+
import urllib.parse
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
import orjson
|
|
9
|
+
|
|
10
|
+
LANGSMITH_METADATA = "langsmith-metadata"
|
|
11
|
+
LANGSMITH_TAGS = "langsmith-tags"
|
|
12
|
+
LANGSMITH_PROJECT = "langsmith-project"
|
|
13
|
+
# For security, don't include these in configuration
|
|
14
|
+
DEFAULT_RUN_HEADERS_EXCLUDE = {"x-api-key", "x-tenant-id", "x-service-key"}
|
|
5
15
|
|
|
6
16
|
|
|
7
17
|
def translate_pattern(pat: str) -> re.Pattern[str]:
|
|
@@ -23,6 +33,62 @@ def translate_pattern(pat: str) -> re.Pattern[str]:
|
|
|
23
33
|
return re.compile(rf"(?s:{pattern})\Z")
|
|
24
34
|
|
|
25
35
|
|
|
36
|
+
def get_configurable_headers(headers: Mapping[str, str]) -> dict[str, str]:
|
|
37
|
+
"""Extract headers that should be added to run configuration.
|
|
38
|
+
|
|
39
|
+
This function handles special cases like langsmith-trace and baggage headers,
|
|
40
|
+
while respecting the configurable header patterns.
|
|
41
|
+
"""
|
|
42
|
+
configurable = {}
|
|
43
|
+
if not headers:
|
|
44
|
+
return configurable
|
|
45
|
+
|
|
46
|
+
for key, value in headers.items():
|
|
47
|
+
# First handle tracing stuff - always included regardless of patterns
|
|
48
|
+
if key == "langsmith-trace":
|
|
49
|
+
configurable[key] = value
|
|
50
|
+
if baggage := headers.get("baggage"):
|
|
51
|
+
for item in baggage.split(","):
|
|
52
|
+
baggage_key, baggage_value = item.split("=")
|
|
53
|
+
if (
|
|
54
|
+
baggage_key == LANGSMITH_METADATA
|
|
55
|
+
and baggage_key not in configurable
|
|
56
|
+
):
|
|
57
|
+
configurable[baggage_key] = orjson.loads(
|
|
58
|
+
urllib.parse.unquote(baggage_value)
|
|
59
|
+
)
|
|
60
|
+
elif baggage_key == LANGSMITH_TAGS:
|
|
61
|
+
configurable[baggage_key] = urllib.parse.unquote(
|
|
62
|
+
baggage_value
|
|
63
|
+
).split(",")
|
|
64
|
+
elif baggage_key == LANGSMITH_PROJECT:
|
|
65
|
+
configurable[baggage_key] = urllib.parse.unquote(baggage_value)
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
# Check if header should be included based on patterns
|
|
69
|
+
# For run configuration, we have specific default behavior for x-* headers
|
|
70
|
+
if key.startswith("x-"):
|
|
71
|
+
# Check against default excludes for x-* headers
|
|
72
|
+
if key in DEFAULT_RUN_HEADERS_EXCLUDE:
|
|
73
|
+
# Check if explicitly included via patterns
|
|
74
|
+
if should_include_header(key):
|
|
75
|
+
configurable[key] = value
|
|
76
|
+
continue
|
|
77
|
+
# Other x-* headers are included by default unless patterns exclude them
|
|
78
|
+
if should_include_header(key):
|
|
79
|
+
configurable[key] = value
|
|
80
|
+
elif key == "user-agent":
|
|
81
|
+
# user-agent is included by default unless excluded by patterns
|
|
82
|
+
if should_include_header(key):
|
|
83
|
+
configurable[key] = value
|
|
84
|
+
else:
|
|
85
|
+
# All other headers only included if patterns allow
|
|
86
|
+
if should_include_header(key):
|
|
87
|
+
configurable[key] = value
|
|
88
|
+
|
|
89
|
+
return configurable
|
|
90
|
+
|
|
91
|
+
|
|
26
92
|
@functools.lru_cache(maxsize=1)
|
|
27
93
|
def get_header_patterns(
|
|
28
94
|
key: str,
|
|
@@ -59,6 +125,14 @@ def should_include_header(key: str) -> bool:
|
|
|
59
125
|
Returns:
|
|
60
126
|
True if the header should be included, False otherwise
|
|
61
127
|
"""
|
|
128
|
+
if (
|
|
129
|
+
key == "x-api-key"
|
|
130
|
+
or key == "x-service-key"
|
|
131
|
+
or key == "x-tenant-id"
|
|
132
|
+
or key == "authorization"
|
|
133
|
+
):
|
|
134
|
+
return False
|
|
135
|
+
|
|
62
136
|
include_patterns, exclude_patterns = get_header_patterns("configurable_headers")
|
|
63
137
|
|
|
64
138
|
return pattern_matches(key, include_patterns, exclude_patterns)
|
|
@@ -85,5 +159,5 @@ def pattern_matches(
|
|
|
85
159
|
# If include patterns are specified, only include headers matching them
|
|
86
160
|
return any(pattern.match(key) for pattern in include_patterns)
|
|
87
161
|
|
|
88
|
-
# Default behavior -
|
|
89
|
-
return
|
|
162
|
+
# Default behavior - exclude
|
|
163
|
+
return False
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import structlog
|
|
5
|
+
|
|
6
|
+
logger = structlog.stdlib.get_logger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def _make_http_request_with_retries(
|
|
10
|
+
url: str,
|
|
11
|
+
headers: dict,
|
|
12
|
+
method: str = "GET",
|
|
13
|
+
json_data: dict | None = None,
|
|
14
|
+
max_retries: int = 3,
|
|
15
|
+
base_delay: float = 1.0,
|
|
16
|
+
) -> httpx.Response | None:
|
|
17
|
+
"""
|
|
18
|
+
Make an HTTP request with exponential backoff retries.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
url: The URL to request
|
|
22
|
+
headers: Headers to include in the request
|
|
23
|
+
method: HTTP method ("GET" or "POST")
|
|
24
|
+
json_data: JSON data for POST requests
|
|
25
|
+
max_retries: Maximum number of retry attempts
|
|
26
|
+
base_delay: Base delay in seconds for exponential backoff
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
httpx.Response: The successful response
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
httpx.HTTPStatusError: If the request fails after all retries
|
|
33
|
+
httpx.RequestError: If the request fails after all retries
|
|
34
|
+
"""
|
|
35
|
+
for attempt in range(max_retries + 1):
|
|
36
|
+
try:
|
|
37
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
38
|
+
response = await client.request(
|
|
39
|
+
method, url, headers=headers, json=json_data
|
|
40
|
+
)
|
|
41
|
+
response.raise_for_status()
|
|
42
|
+
return response
|
|
43
|
+
|
|
44
|
+
except (
|
|
45
|
+
httpx.TimeoutException,
|
|
46
|
+
httpx.NetworkError,
|
|
47
|
+
httpx.RequestError,
|
|
48
|
+
httpx.HTTPStatusError,
|
|
49
|
+
) as e:
|
|
50
|
+
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code < 500:
|
|
51
|
+
# Don't retry on 4xx errors, but do on 5xxs
|
|
52
|
+
raise e
|
|
53
|
+
|
|
54
|
+
# Back off and retry if we haven't reached the max retries
|
|
55
|
+
if attempt < max_retries:
|
|
56
|
+
delay = base_delay * (2**attempt) # Exponential backoff
|
|
57
|
+
logger.warning(
|
|
58
|
+
"HTTP %s request attempt %d to %s failed: %s. Retrying in %.1f seconds...",
|
|
59
|
+
method,
|
|
60
|
+
attempt + 1,
|
|
61
|
+
url,
|
|
62
|
+
e,
|
|
63
|
+
delay,
|
|
64
|
+
)
|
|
65
|
+
await asyncio.sleep(delay)
|
|
66
|
+
else:
|
|
67
|
+
logger.exception(
|
|
68
|
+
"HTTP %s request to %s failed after %d attempts. Last error: %s",
|
|
69
|
+
method,
|
|
70
|
+
url,
|
|
71
|
+
max_retries + 1,
|
|
72
|
+
e,
|
|
73
|
+
)
|
|
74
|
+
raise e
|