langgraph-api 0.5.4__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 +93 -27
- langgraph_api/api/a2a.py +36 -32
- langgraph_api/api/assistants.py +114 -26
- langgraph_api/api/mcp.py +3 -3
- langgraph_api/api/meta.py +15 -2
- langgraph_api/api/openapi.py +27 -17
- langgraph_api/api/profile.py +108 -0
- langgraph_api/api/runs.py +114 -57
- langgraph_api/api/store.py +19 -2
- langgraph_api/api/threads.py +133 -10
- langgraph_api/asgi_transport.py +14 -9
- langgraph_api/auth/custom.py +23 -13
- langgraph_api/cli.py +86 -41
- langgraph_api/command.py +2 -2
- 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 +19 -0
- langgraph_api/graph.py +163 -64
- langgraph_api/{grpc_ops → grpc}/client.py +142 -12
- langgraph_api/{grpc_ops → grpc}/config_conversion.py +16 -10
- 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_ops → grpc}/generated/core_api_pb2.pyi +292 -372
- langgraph_api/{grpc_ops → grpc}/generated/core_api_pb2_grpc.py +252 -31
- langgraph_api/grpc/generated/engine_common_pb2.py +219 -0
- langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2.pyi +178 -104
- 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/js/client.mts +1 -4
- langgraph_api/js/package.json +28 -27
- langgraph_api/js/remote.py +39 -17
- langgraph_api/js/sse.py +2 -2
- langgraph_api/js/ui.py +1 -1
- langgraph_api/js/yarn.lock +1139 -869
- langgraph_api/metadata.py +29 -3
- langgraph_api/middleware/http_logger.py +1 -1
- langgraph_api/middleware/private_network.py +7 -7
- langgraph_api/models/run.py +44 -26
- langgraph_api/otel_context.py +205 -0
- langgraph_api/patch.py +2 -2
- langgraph_api/queue_entrypoint.py +34 -35
- langgraph_api/route.py +33 -1
- langgraph_api/schema.py +84 -9
- langgraph_api/self_hosted_logs.py +2 -2
- langgraph_api/self_hosted_metrics.py +73 -3
- langgraph_api/serde.py +16 -4
- langgraph_api/server.py +33 -31
- langgraph_api/state.py +3 -2
- langgraph_api/store.py +25 -16
- langgraph_api/stream.py +20 -16
- langgraph_api/thread_ttl.py +28 -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/config.py +2 -1
- langgraph_api/utils/future.py +10 -6
- langgraph_api/utils/uuids.py +29 -62
- langgraph_api/validation.py +6 -0
- langgraph_api/webhook.py +120 -6
- langgraph_api/worker.py +54 -24
- {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/METADATA +8 -6
- langgraph_api-0.7.3.dist-info/RECORD +168 -0
- {langgraph_api-0.5.4.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 +635 -537
- langgraph_api/config.py +0 -523
- langgraph_api/grpc_ops/generated/__init__.py +0 -5
- langgraph_api/grpc_ops/generated/core_api_pb2.py +0 -275
- langgraph_api/grpc_ops/generated/engine_common_pb2.py +0 -194
- langgraph_api/grpc_ops/ops.py +0 -1045
- langgraph_api-0.5.4.dist-info/RECORD +0 -121
- /langgraph_api/{grpc_ops → grpc}/__init__.py +0 -0
- /langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2_grpc.py +0 -0
- {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/entry_points.txt +0 -0
- {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from typing import Annotated, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic.functional_validators import AfterValidator
|
|
6
|
+
from typing_extensions import TypedDict
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"CheckpointerConfig",
|
|
10
|
+
"ConfigurableHeaders",
|
|
11
|
+
"CorsConfig",
|
|
12
|
+
"EncryptionConfig",
|
|
13
|
+
"HttpConfig",
|
|
14
|
+
"IndexConfig",
|
|
15
|
+
"MiddlewareOrders",
|
|
16
|
+
"SecurityConfig",
|
|
17
|
+
"SerdeConfig",
|
|
18
|
+
"StoreConfig",
|
|
19
|
+
"TTLConfig",
|
|
20
|
+
"ThreadTTLConfig",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CorsConfig(TypedDict, total=False):
|
|
25
|
+
allow_origins: list[str]
|
|
26
|
+
"""List of origins allowed to access the API (e.g., ["https://app.com"]).
|
|
27
|
+
|
|
28
|
+
Use ["*"] to allow any origin. Combined with `allow_origin_regex` when both are set.
|
|
29
|
+
"""
|
|
30
|
+
allow_methods: list[str]
|
|
31
|
+
"""HTTP methods permitted for cross-origin requests (e.g., ["GET", "POST"])."""
|
|
32
|
+
allow_headers: list[str]
|
|
33
|
+
"""Request headers clients may send in cross-origin requests (e.g., ["authorization"])."""
|
|
34
|
+
allow_credentials: bool
|
|
35
|
+
"""Whether browsers may include credentials (cookies, Authorization) in cross-origin requests."""
|
|
36
|
+
allow_origin_regex: str
|
|
37
|
+
"""Regular expression that matches allowed origins; evaluated against the request Origin header."""
|
|
38
|
+
expose_headers: list[str]
|
|
39
|
+
"""Response headers that browsers are allowed to read from CORS responses."""
|
|
40
|
+
max_age: int
|
|
41
|
+
"""Number of seconds browsers may cache the CORS preflight (OPTIONS) response."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConfigurableHeaders(TypedDict, total=False):
|
|
45
|
+
includes: list[str] | None
|
|
46
|
+
"""Header name patterns to include.
|
|
47
|
+
|
|
48
|
+
Patterns support literal names and the "*" wildcard (e.g., "x-*", "user-agent").
|
|
49
|
+
Matching is performed against lower-cased header names.
|
|
50
|
+
"""
|
|
51
|
+
excludes: list[str] | None
|
|
52
|
+
"""Header name patterns to exclude after inclusion rules are applied."""
|
|
53
|
+
include: list[str] | None
|
|
54
|
+
"""Alias of `includes` for convenience/backwards compatibility."""
|
|
55
|
+
exclude: list[str] | None
|
|
56
|
+
"""Alias of `excludes` for convenience/backwards compatibility."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
MiddlewareOrders = Literal["auth_first", "middleware_first"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class HttpConfig(TypedDict, total=False):
|
|
63
|
+
app: str
|
|
64
|
+
"""Import path for a custom Starlette/FastAPI app to mount"""
|
|
65
|
+
disable_assistants: bool
|
|
66
|
+
"""Disable /assistants routes"""
|
|
67
|
+
disable_threads: bool
|
|
68
|
+
"""Disable /threads routes"""
|
|
69
|
+
disable_runs: bool
|
|
70
|
+
"""Disable /runs routes"""
|
|
71
|
+
disable_store: bool
|
|
72
|
+
"""Disable /store routes"""
|
|
73
|
+
disable_meta: bool
|
|
74
|
+
"""Disable /ok, /info, /metrics, and /docs routes"""
|
|
75
|
+
disable_webhooks: bool
|
|
76
|
+
"""Disable webhooks calls on run completion in all routes"""
|
|
77
|
+
cors: CorsConfig | None
|
|
78
|
+
"""CORS configuration for all mounted routes; see CorsConfig for details."""
|
|
79
|
+
disable_ui: bool
|
|
80
|
+
"""Disable /ui routes"""
|
|
81
|
+
disable_mcp: bool
|
|
82
|
+
"""Disable /mcp routes"""
|
|
83
|
+
disable_a2a: bool
|
|
84
|
+
"""Disable /a2a routes"""
|
|
85
|
+
mount_prefix: str
|
|
86
|
+
"""Prefix for mounted routes. E.g., "/my-deployment/api"."""
|
|
87
|
+
configurable_headers: ConfigurableHeaders | None
|
|
88
|
+
"""Controls which inbound request headers are surfaced to runs as `config.configurable`.
|
|
89
|
+
|
|
90
|
+
Only headers that match `includes` and do not match `excludes` are exposed, except
|
|
91
|
+
for tracing headers like `langsmith-trace` and select `baggage` keys which are
|
|
92
|
+
always forwarded. Patterns are matched case-insensitively against lower-cased names.
|
|
93
|
+
"""
|
|
94
|
+
logging_headers: ConfigurableHeaders | None
|
|
95
|
+
"""Controls which inbound request headers may appear in access/application logs.
|
|
96
|
+
|
|
97
|
+
Use restrictive patterns (e.g., include "user-agent", exclude "authorization") to
|
|
98
|
+
avoid logging sensitive information.
|
|
99
|
+
"""
|
|
100
|
+
enable_custom_route_auth: bool
|
|
101
|
+
"""If true, apply the configured authentication middleware to user-supplied routes."""
|
|
102
|
+
middleware_order: MiddlewareOrders | None
|
|
103
|
+
"""Ordering for authentication vs custom middleware on user routes.
|
|
104
|
+
|
|
105
|
+
- "auth_first": run auth middleware before user middleware
|
|
106
|
+
- "middleware_first": run user middleware before auth (default behavior)
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ThreadTTLConfig(TypedDict, total=False):
|
|
111
|
+
strategy: Literal["delete", "keep_latest"]
|
|
112
|
+
"""Action taken when a thread exceeds its TTL.
|
|
113
|
+
|
|
114
|
+
- "delete": Remove the thread and all its data entirely.
|
|
115
|
+
- "keep_latest": Prune old checkpoints but keep the thread and its latest state.
|
|
116
|
+
Requires core API (FF_USE_CORE_API=true).
|
|
117
|
+
"""
|
|
118
|
+
default_ttl: float | None
|
|
119
|
+
"""Default thread TTL in minutes; threads past this age are subject to the `strategy`."""
|
|
120
|
+
sweep_interval_minutes: int | None
|
|
121
|
+
"""How often to scan for expired threads, in minutes."""
|
|
122
|
+
sweep_limit: int | None
|
|
123
|
+
"""Maximum number of threads to process per sweep iteration. Defaults to 1000."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class IndexConfig(TypedDict, total=False):
|
|
127
|
+
"""Configuration for indexing documents for semantic search in the store."""
|
|
128
|
+
|
|
129
|
+
dims: int
|
|
130
|
+
"""Number of dimensions in the embedding vectors.
|
|
131
|
+
|
|
132
|
+
Common embedding models have the following dimensions:
|
|
133
|
+
- OpenAI text-embedding-3-large: 256, 1024, or 3072
|
|
134
|
+
- OpenAI text-embedding-3-small: 512 or 1536
|
|
135
|
+
- OpenAI text-embedding-ada-002: 1536
|
|
136
|
+
- Cohere embed-english-v3.0: 1024
|
|
137
|
+
- Cohere embed-english-light-v3.0: 384
|
|
138
|
+
- Cohere embed-multilingual-v3.0: 1024
|
|
139
|
+
- Cohere embed-multilingual-light-v3.0: 384
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
embed: str
|
|
143
|
+
"""Either a path to an embedding model (./path/to/file.py:embedding_model)
|
|
144
|
+
or a name of an embedding model (openai:text-embedding-3-small)
|
|
145
|
+
|
|
146
|
+
Note: LangChain is required to use the model format specification.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
fields: list[str] | None
|
|
150
|
+
"""Fields to extract text from for embedding generation.
|
|
151
|
+
|
|
152
|
+
Defaults to the root ["$"], which embeds the json object as a whole.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TTLConfig(TypedDict, total=False):
|
|
157
|
+
"""Configuration for TTL (time-to-live) behavior in the store."""
|
|
158
|
+
|
|
159
|
+
refresh_on_read: bool
|
|
160
|
+
"""Default behavior for refreshing TTLs on read operations (GET and SEARCH).
|
|
161
|
+
|
|
162
|
+
If True, TTLs will be refreshed on read operations (get/search) by default.
|
|
163
|
+
This can be overridden per-operation by explicitly setting refresh_ttl.
|
|
164
|
+
Defaults to True if not configured.
|
|
165
|
+
"""
|
|
166
|
+
default_ttl: float | None
|
|
167
|
+
"""Default TTL (time-to-live) in minutes for new items.
|
|
168
|
+
|
|
169
|
+
If provided, new items will expire after this many minutes after their last access.
|
|
170
|
+
The expiration timer refreshes on both read and write operations.
|
|
171
|
+
Defaults to None (no expiration).
|
|
172
|
+
"""
|
|
173
|
+
sweep_interval_minutes: int | None
|
|
174
|
+
"""Interval in minutes between TTL sweep operations.
|
|
175
|
+
|
|
176
|
+
If provided, the store will periodically delete expired items based on TTL.
|
|
177
|
+
Defaults to None (no sweeping).
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class StoreConfig(TypedDict, total=False):
|
|
182
|
+
path: str
|
|
183
|
+
"""Import path to a custom `BaseStore` or a callable returning one.
|
|
184
|
+
|
|
185
|
+
Examples:
|
|
186
|
+
- "./my_store.py:create_store"
|
|
187
|
+
- "my_package.store:Store"
|
|
188
|
+
|
|
189
|
+
When provided, this replaces the default Postgres-backed store.
|
|
190
|
+
"""
|
|
191
|
+
index: IndexConfig
|
|
192
|
+
"""Vector index settings for the built-in store (ignored by custom stores)."""
|
|
193
|
+
ttl: TTLConfig
|
|
194
|
+
"""TTL behavior for stored items in the built-in store (custom stores may not support TTL)."""
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class SerdeConfig(TypedDict, total=False):
|
|
198
|
+
"""Configuration for the built-in serde, which handles checkpointing of state.
|
|
199
|
+
|
|
200
|
+
If omitted, no serde is set up (the object store will still be present, however)."""
|
|
201
|
+
|
|
202
|
+
allowed_json_modules: list[list[str]] | Literal[True] | None
|
|
203
|
+
"""Optional. List of allowed python modules to de-serialize custom objects from.
|
|
204
|
+
|
|
205
|
+
If provided, only the specified modules will be allowed to be deserialized.
|
|
206
|
+
If omitted, no modules are allowed, and the object returned will simply be a json object OR
|
|
207
|
+
a deserialized langchain object.
|
|
208
|
+
|
|
209
|
+
Example:
|
|
210
|
+
{...
|
|
211
|
+
"serde": {
|
|
212
|
+
"allowed_json_modules": [
|
|
213
|
+
["my_agent", "my_file", "SomeType"],
|
|
214
|
+
]
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
If you set this to True, any module will be allowed to be deserialized.
|
|
219
|
+
|
|
220
|
+
Example:
|
|
221
|
+
{...
|
|
222
|
+
"serde": {
|
|
223
|
+
"allowed_json_modules": true
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
"""
|
|
228
|
+
pickle_fallback: bool
|
|
229
|
+
"""Optional. Whether to allow pickling as a fallback for deserialization.
|
|
230
|
+
|
|
231
|
+
If True, pickling will be allowed as a fallback for deserialization.
|
|
232
|
+
If False, pickling will not be allowed as a fallback for deserialization.
|
|
233
|
+
Defaults to True if not configured."""
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class CheckpointerConfig(TypedDict, total=False):
|
|
237
|
+
"""Configuration for the built-in checkpointer, which handles checkpointing of state.
|
|
238
|
+
|
|
239
|
+
If omitted, no checkpointer is set up (the object store will still be present, however).
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
ttl: ThreadTTLConfig | None
|
|
243
|
+
"""Optional. Defines the TTL (time-to-live) behavior configuration.
|
|
244
|
+
|
|
245
|
+
If provided, the checkpointer will apply TTL settings according to the configuration.
|
|
246
|
+
If omitted, no TTL behavior is configured.
|
|
247
|
+
"""
|
|
248
|
+
serde: SerdeConfig | None
|
|
249
|
+
"""Optional. Defines the configuration for how checkpoints are serialized."""
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class SecurityConfig(TypedDict, total=False):
|
|
253
|
+
securitySchemes: dict
|
|
254
|
+
"""OpenAPI `components.securitySchemes` definition to merge into the spec."""
|
|
255
|
+
security: list
|
|
256
|
+
"""Default security requirements applied to all operations (OpenAPI `security`)."""
|
|
257
|
+
# path => {method => security}
|
|
258
|
+
paths: dict[str, dict[str, list]]
|
|
259
|
+
"""Per-route overrides of security requirements.
|
|
260
|
+
|
|
261
|
+
Mapping of path -> method -> OpenAPI `security` array.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class CacheConfig(TypedDict, total=False):
|
|
266
|
+
cache_keys: list[str]
|
|
267
|
+
"""Header names used to build the cache key for auth decisions."""
|
|
268
|
+
ttl_seconds: int
|
|
269
|
+
"""How long to cache successful auth decisions, in seconds."""
|
|
270
|
+
max_size: int
|
|
271
|
+
"""Maximum number of distinct auth cache entries to retain."""
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class AuthConfig(TypedDict, total=False):
|
|
275
|
+
path: str
|
|
276
|
+
"""Path to the authentication function in a Python file."""
|
|
277
|
+
disable_studio_auth: bool
|
|
278
|
+
"""Whether to disable auth when connecting from the LangSmith Studio."""
|
|
279
|
+
openapi: SecurityConfig
|
|
280
|
+
"""The schema to use for updating the openapi spec.
|
|
281
|
+
|
|
282
|
+
Example:
|
|
283
|
+
{
|
|
284
|
+
"securitySchemes": {
|
|
285
|
+
"OAuth2": {
|
|
286
|
+
"type": "oauth2",
|
|
287
|
+
"flows": {
|
|
288
|
+
"password": {
|
|
289
|
+
"tokenUrl": "/token",
|
|
290
|
+
"scopes": {
|
|
291
|
+
"me": "Read information about the current user",
|
|
292
|
+
"items": "Access to create and manage items"
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
"security": [
|
|
299
|
+
{"OAuth2": ["me"]} # Default security requirement for all endpoints
|
|
300
|
+
]
|
|
301
|
+
}
|
|
302
|
+
"""
|
|
303
|
+
cache: CacheConfig | None
|
|
304
|
+
"""Optional cache settings for the custom auth backend to reduce repeated lookups."""
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class WebhookUrlPolicy(TypedDict, total=False):
|
|
308
|
+
require_https: bool
|
|
309
|
+
"""Enforce HTTPS scheme for absolute URLs; reject `http://` when true."""
|
|
310
|
+
allowed_domains: list[str]
|
|
311
|
+
"""Hostname allowlist. Supports exact hosts and wildcard subdomains.
|
|
312
|
+
|
|
313
|
+
Use entries like "hooks.example.com" or "*.mycorp.com". The wildcard only
|
|
314
|
+
matches subdomains ("foo.mycorp.com"), not the apex ("mycorp.com"). When
|
|
315
|
+
empty or omitted, any public host is allowed (subject to SSRF IP checks).
|
|
316
|
+
"""
|
|
317
|
+
allowed_ports: list[int]
|
|
318
|
+
"""Explicit port allowlist for absolute URLs.
|
|
319
|
+
|
|
320
|
+
If set, requests must use one of these ports. Defaults are respected when
|
|
321
|
+
a port is not present in the URL (443 for https, 80 for http).
|
|
322
|
+
"""
|
|
323
|
+
max_url_length: int
|
|
324
|
+
"""Maximum permitted URL length in characters; longer inputs are rejected early."""
|
|
325
|
+
disable_loopback: bool
|
|
326
|
+
"""Disallow relative URLs (internal loopback calls) when true."""
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# Matches things like "${{ env.LG_WEBHOOK_FOO_BAR }}"
|
|
330
|
+
_WEBHOOK_TEMPLATE_RE = re.compile(r"\$\{\{\s*([^}]+?)\s*\}\}")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _validate_url_policy(
|
|
334
|
+
policy: "WebhookUrlPolicy | None",
|
|
335
|
+
) -> "WebhookUrlPolicy | None":
|
|
336
|
+
if not policy:
|
|
337
|
+
return policy
|
|
338
|
+
if "allowed_domains" in policy:
|
|
339
|
+
doms = policy["allowed_domains"]
|
|
340
|
+
if not isinstance(doms, list) or not all(
|
|
341
|
+
isinstance(d, str) and d for d in doms
|
|
342
|
+
):
|
|
343
|
+
raise ValueError(
|
|
344
|
+
f"webhooks.url.allowed_domains must be a list of non-empty strings. Got: {doms}"
|
|
345
|
+
)
|
|
346
|
+
for d in doms:
|
|
347
|
+
if "*" in d and not d.startswith("*."):
|
|
348
|
+
raise ValueError(
|
|
349
|
+
f"webhooks.url.allowed_domains wildcard can only be used as a prefix, in the form '*.domain'. Got: {doms}"
|
|
350
|
+
)
|
|
351
|
+
if "require_https" not in policy:
|
|
352
|
+
policy["require_https"] = True
|
|
353
|
+
if "allowed_domains" not in policy:
|
|
354
|
+
policy["allowed_domains"] = []
|
|
355
|
+
if "allowed_ports" not in policy:
|
|
356
|
+
policy["allowed_ports"] = []
|
|
357
|
+
if "max_url_length" not in policy:
|
|
358
|
+
policy["max_url_length"] = 2048
|
|
359
|
+
if "disable_loopback" not in policy:
|
|
360
|
+
policy["disable_loopback"] = False
|
|
361
|
+
return policy
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class WebhooksConfig(TypedDict, total=False):
|
|
365
|
+
env_prefix: str
|
|
366
|
+
"""Required prefix for environment variables referenced in header templates.
|
|
367
|
+
|
|
368
|
+
Acts as an allowlist boundary to prevent leaking arbitrary environment
|
|
369
|
+
variables. Defaults to "LG_WEBHOOK_" when omitted.
|
|
370
|
+
"""
|
|
371
|
+
url: Annotated[WebhookUrlPolicy, AfterValidator(_validate_url_policy)]
|
|
372
|
+
"""URL validation policy for user-supplied webhook endpoints."""
|
|
373
|
+
headers: dict[str, str]
|
|
374
|
+
"""Static headers to include with webhook requests.
|
|
375
|
+
|
|
376
|
+
Values may contain templates of the form "${{ env.VAR }}". On startup, these
|
|
377
|
+
are resolved via the process environment after verifying `VAR` starts with
|
|
378
|
+
`env_prefix`. Mixed literals and multiple templates are allowed.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def webhooks_validator(cfg: "WebhooksConfig") -> "WebhooksConfig":
|
|
383
|
+
# Enforce env prefix & actual env presence at the aggregate level
|
|
384
|
+
headers = cfg.get("headers") or {}
|
|
385
|
+
if headers:
|
|
386
|
+
env_prefix = cfg.get("env_prefix", "LG_WEBHOOK_")
|
|
387
|
+
if env_prefix is None:
|
|
388
|
+
raise ValueError("webhook headers: Invalid null env_prefix")
|
|
389
|
+
|
|
390
|
+
def _replace(m: re.Match[str]) -> str:
|
|
391
|
+
expr = m.group(1).strip()
|
|
392
|
+
# Only variables we support are of the form "env.FOO_BAR" right now.
|
|
393
|
+
if not expr.startswith("env."):
|
|
394
|
+
raise ValueError(
|
|
395
|
+
f"webhook headers: only env.VAR references are allowed (e.g. {{{{ env.{env_prefix}FOO_BAR }}}})"
|
|
396
|
+
)
|
|
397
|
+
var = expr[len("env.") :]
|
|
398
|
+
if not var or "." in var:
|
|
399
|
+
raise ValueError(
|
|
400
|
+
f"webhook headers: invalid env reference '{var}'. Use env.VAR with no dots (e.g. {{{{ env.{env_prefix}FOO_BAR }}}})"
|
|
401
|
+
)
|
|
402
|
+
if env_prefix and not var.startswith(env_prefix):
|
|
403
|
+
raise ValueError(
|
|
404
|
+
f"webhook headers: environment variable name '{var}' must start with the configured env_prefix '{env_prefix}'"
|
|
405
|
+
)
|
|
406
|
+
val = os.getenv(var)
|
|
407
|
+
if val is None:
|
|
408
|
+
raise ValueError(
|
|
409
|
+
f"webhook headers: missing required environment variable '{var}'"
|
|
410
|
+
)
|
|
411
|
+
return val
|
|
412
|
+
|
|
413
|
+
rendered_headers = {}
|
|
414
|
+
for k, v in headers.items():
|
|
415
|
+
if not isinstance(v, str):
|
|
416
|
+
raise ValueError(f"Webhook header values must be strings. Got: {v}")
|
|
417
|
+
rendered = _WEBHOOK_TEMPLATE_RE.sub(_replace, v)
|
|
418
|
+
rendered_headers[k] = rendered
|
|
419
|
+
cfg["headers"] = rendered_headers
|
|
420
|
+
return cfg
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class EncryptionConfig(TypedDict, total=False):
|
|
424
|
+
path: str
|
|
425
|
+
"""Path to the encryption module in a Python file.
|
|
426
|
+
|
|
427
|
+
Example: "./encryption.py:my_encryption"
|
|
428
|
+
|
|
429
|
+
The module should export an Encrypt instance with registered
|
|
430
|
+
encryption and decryption handlers for blobs and metadata.
|
|
431
|
+
"""
|
langgraph_api/cron_scheduler.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
from random import random
|
|
3
|
+
from typing import cast
|
|
3
4
|
|
|
4
5
|
import structlog
|
|
5
6
|
|
|
7
|
+
from langgraph_api import config
|
|
8
|
+
from langgraph_api.encryption.middleware import decrypt_response
|
|
6
9
|
from langgraph_api.models.run import create_valid_run
|
|
10
|
+
from langgraph_api.serde import json_loads
|
|
7
11
|
from langgraph_api.utils import next_cron_date
|
|
8
12
|
from langgraph_api.utils.config import run_in_executor
|
|
9
13
|
from langgraph_api.worker import set_auth_ctx_for_run
|
|
@@ -13,7 +17,7 @@ from langgraph_runtime.retry import retry_db
|
|
|
13
17
|
|
|
14
18
|
logger = structlog.stdlib.get_logger(__name__)
|
|
15
19
|
|
|
16
|
-
SLEEP_TIME =
|
|
20
|
+
SLEEP_TIME = config.CRON_SCHEDULER_SLEEP_TIME
|
|
17
21
|
|
|
18
22
|
|
|
19
23
|
@retry_db
|
|
@@ -23,7 +27,19 @@ async def cron_scheduler():
|
|
|
23
27
|
try:
|
|
24
28
|
async with connect() as conn:
|
|
25
29
|
async for cron in Crons.next(conn):
|
|
30
|
+
on_run_completed = cron.get("on_run_completed")
|
|
31
|
+
|
|
26
32
|
run_payload = cron["payload"]
|
|
33
|
+
if not isinstance(run_payload, dict):
|
|
34
|
+
run_payload = json_loads(run_payload)
|
|
35
|
+
run_payload = cast("dict", run_payload)
|
|
36
|
+
|
|
37
|
+
run_payload = await decrypt_response(
|
|
38
|
+
run_payload, "cron", ["metadata", "context", "input", "config"]
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if on_run_completed == "keep":
|
|
42
|
+
run_payload.setdefault("on_completion", "keep") # type: ignore[union-attr]
|
|
27
43
|
|
|
28
44
|
async with set_auth_ctx_for_run(
|
|
29
45
|
run_payload, user_id=cron["user_id"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Encryption support for LangGraph API."""
|
|
2
|
+
|
|
3
|
+
from langgraph_api.encryption.custom import (
|
|
4
|
+
SUPPORTED_ENCRYPTION_MODELS,
|
|
5
|
+
ModelType,
|
|
6
|
+
get_custom_encryption_instance,
|
|
7
|
+
)
|
|
8
|
+
from langgraph_api.encryption.shared import get_encryption
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"SUPPORTED_ENCRYPTION_MODELS",
|
|
12
|
+
"ModelType",
|
|
13
|
+
"get_custom_encryption_instance",
|
|
14
|
+
"get_encryption",
|
|
15
|
+
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""AES encryption for JSON field values.
|
|
2
|
+
|
|
3
|
+
This module provides opt-in AES encryption for specific JSON keys,
|
|
4
|
+
using the same key and cipher as checkpoint encryption (LANGGRAPH_AES_KEY).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import functools
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
import orjson
|
|
14
|
+
from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
|
|
15
|
+
|
|
16
|
+
from langgraph_api.encryption.shared import (
|
|
17
|
+
ENCRYPTION_CONTEXT_KEY,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from langgraph.checkpoint.serde.base import CipherProtocol
|
|
22
|
+
from langgraph_sdk.encryption.types import JsonDecryptor, JsonEncryptor
|
|
23
|
+
|
|
24
|
+
from langgraph_api.encryption.custom import ModelType
|
|
25
|
+
|
|
26
|
+
AES_ENCRYPTED_PREFIX = "encrypted.aes:"
|
|
27
|
+
|
|
28
|
+
# Marker key to identify AES encryption in __encryption_context__
|
|
29
|
+
AES_ENCRYPTION_TYPE_KEY = "__langgraph_encryption_type__"
|
|
30
|
+
AES_ENCRYPTION_CONTEXT = {AES_ENCRYPTION_TYPE_KEY: "aes"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_aes_cipher(key: bytes) -> CipherProtocol:
|
|
34
|
+
"""Get AES cipher using the SDK's implementation (same as checkpoint encryption)."""
|
|
35
|
+
# EncryptedSerializer.from_pycryptodome_aes creates a PycryptodomeAesCipher internally
|
|
36
|
+
# We extract it to reuse the exact same encryption format as checkpoints
|
|
37
|
+
return EncryptedSerializer.from_pycryptodome_aes(key=key).cipher
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_aes_encrypted(value: Any) -> bool:
|
|
41
|
+
"""Check if a value is AES-encrypted (has the encryption prefix)."""
|
|
42
|
+
return isinstance(value, str) and value.startswith(AES_ENCRYPTED_PREFIX)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def has_any_aes_encrypted_values(data: dict[str, Any]) -> bool:
|
|
46
|
+
"""Check if any value in the dict is AES-encrypted (top-level only)."""
|
|
47
|
+
return isinstance(data, dict) and any(is_aes_encrypted(v) for v in data.values())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_aes_encryption_context(ctx: dict[str, Any] | None) -> bool:
|
|
51
|
+
"""Check if an encryption context indicates AES encryption."""
|
|
52
|
+
return isinstance(ctx, dict) and ctx.get(AES_ENCRYPTION_TYPE_KEY) == "aes"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EncryptionKeyError(Exception):
|
|
56
|
+
"""Raised when JSON encryptor violates key preservation constraint."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class EncryptionRoutingError(Exception):
|
|
60
|
+
"""Raised when encryption routing fails due to inconsistent markers."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DecryptorMissingError(Exception):
|
|
64
|
+
"""Raised when data has encryption marker but no decryptor is configured."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AesEncryptionInstance:
|
|
68
|
+
"""Built-in AES encryption for JSON field values.
|
|
69
|
+
|
|
70
|
+
Uses the same AES cipher as checkpoint encryption (via SDK's CipherProtocol).
|
|
71
|
+
Duck-types the SDK's Encryption interface for use in the middleware.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, key: bytes, allowlist: frozenset[str]) -> None:
|
|
75
|
+
self._cipher = _get_aes_cipher(key)
|
|
76
|
+
self._allowlist = allowlist
|
|
77
|
+
|
|
78
|
+
def encrypt_value(self, value: Any) -> str:
|
|
79
|
+
"""Encrypt a JSON-serializable value, returning prefixed ciphertext."""
|
|
80
|
+
plaintext = orjson.dumps(value)
|
|
81
|
+
_, encrypted_blob = self._cipher.encrypt(plaintext)
|
|
82
|
+
encoded = base64.b64encode(encrypted_blob).decode("ascii")
|
|
83
|
+
return f"{AES_ENCRYPTED_PREFIX}{encoded}"
|
|
84
|
+
|
|
85
|
+
def decrypt_value(self, encrypted: str) -> Any:
|
|
86
|
+
"""Decrypt an AES-encrypted value."""
|
|
87
|
+
if not encrypted.startswith(AES_ENCRYPTED_PREFIX):
|
|
88
|
+
raise ValueError(f"Expected prefix '{AES_ENCRYPTED_PREFIX}'")
|
|
89
|
+
encrypted_blob = base64.b64decode(encrypted[len(AES_ENCRYPTED_PREFIX) :])
|
|
90
|
+
plaintext = self._cipher.decrypt("aes", encrypted_blob)
|
|
91
|
+
return orjson.loads(plaintext)
|
|
92
|
+
|
|
93
|
+
def encrypt_json(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
94
|
+
"""Encrypt allowlisted keys in a JSON dict."""
|
|
95
|
+
if not data:
|
|
96
|
+
return data
|
|
97
|
+
keys_to_encrypt = data.keys() & self._allowlist
|
|
98
|
+
if not keys_to_encrypt:
|
|
99
|
+
return data
|
|
100
|
+
result = {
|
|
101
|
+
k: self.encrypt_value(v) if k in keys_to_encrypt and v is not None else v
|
|
102
|
+
for k, v in data.items()
|
|
103
|
+
}
|
|
104
|
+
result[ENCRYPTION_CONTEXT_KEY] = AES_ENCRYPTION_CONTEXT.copy()
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
def decrypt_json(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
108
|
+
"""Decrypt all AES-encrypted values in a JSON dict."""
|
|
109
|
+
if not data:
|
|
110
|
+
return data
|
|
111
|
+
return {
|
|
112
|
+
k: self.decrypt_value(v) if is_aes_encrypted(v) else v
|
|
113
|
+
for k, v in data.items()
|
|
114
|
+
if k != ENCRYPTION_CONTEXT_KEY
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
def get_json_encryptor(self, model_type: ModelType) -> JsonEncryptor:
|
|
118
|
+
"""Return an async encryptor function for the given model type."""
|
|
119
|
+
|
|
120
|
+
async def encryptor(ctx: Any, data: dict[str, Any]) -> dict[str, Any]:
|
|
121
|
+
return self.encrypt_json(data)
|
|
122
|
+
|
|
123
|
+
return encryptor
|
|
124
|
+
|
|
125
|
+
def get_json_decryptor(self, model_type: ModelType) -> JsonDecryptor:
|
|
126
|
+
"""Return an async decryptor function for the given model type."""
|
|
127
|
+
|
|
128
|
+
async def decryptor(ctx: Any, data: dict[str, Any]) -> dict[str, Any]:
|
|
129
|
+
return self.decrypt_json(data)
|
|
130
|
+
|
|
131
|
+
return decryptor
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@functools.lru_cache(maxsize=1)
|
|
135
|
+
def get_aes_encryption_instance() -> AesEncryptionInstance | None:
|
|
136
|
+
"""Get the AES encryption instance if configured.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
- AesEncryptionInstance with allowlist if both LANGGRAPH_AES_KEY and
|
|
140
|
+
LANGGRAPH_AES_JSON_KEYS are configured (encrypts and decrypts)
|
|
141
|
+
- AesEncryptionInstance with empty allowlist if only LANGGRAPH_AES_KEY
|
|
142
|
+
is configured (decrypts only, for migration from AES to custom)
|
|
143
|
+
- None if LANGGRAPH_AES_KEY is not configured
|
|
144
|
+
"""
|
|
145
|
+
# Import here to avoid circular imports
|
|
146
|
+
from langgraph_api.config import LANGGRAPH_AES_JSON_KEYS, LANGGRAPH_AES_KEY
|
|
147
|
+
|
|
148
|
+
if not LANGGRAPH_AES_KEY:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
# If both key and json_keys are set, use full encryption/decryption
|
|
152
|
+
if LANGGRAPH_AES_JSON_KEYS:
|
|
153
|
+
return AesEncryptionInstance(LANGGRAPH_AES_KEY, LANGGRAPH_AES_JSON_KEYS)
|
|
154
|
+
|
|
155
|
+
# If only key is set (no json_keys), create decryption-only instance.
|
|
156
|
+
# This supports migration from AES to custom encryption: the server can
|
|
157
|
+
# decrypt old AES-encrypted data without re-encrypting new data with AES.
|
|
158
|
+
return AesEncryptionInstance(LANGGRAPH_AES_KEY, frozenset())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Request-scoped encryption context storage.
|
|
2
|
+
|
|
3
|
+
This module provides a ContextVar for storing encryption context
|
|
4
|
+
(tenant ID, key identifiers, etc.) that is accessible throughout
|
|
5
|
+
the async request lifecycle, including in checkpoint serialization.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
# Request-scoped encryption context
|
|
12
|
+
# Set by API middleware when X-Encryption-Context header is present
|
|
13
|
+
# Accessed by serializers during checkpoint encryption/decryption
|
|
14
|
+
encryption_context: ContextVar[dict[str, Any] | None] = ContextVar( # type: ignore[assignment]
|
|
15
|
+
"encryption_context", default=None
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_encryption_context() -> dict[str, Any]:
|
|
20
|
+
"""Get the current request's encryption context.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
The encryption context dict, or empty dict if not set.
|
|
24
|
+
"""
|
|
25
|
+
ctx = encryption_context.get()
|
|
26
|
+
return ctx if ctx is not None else {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def set_encryption_context(context: dict[str, Any]) -> None:
|
|
30
|
+
"""Set the encryption context for the current request.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
context: The encryption context dict (e.g., {"tenant_id": "...", "key_id": "..."})
|
|
34
|
+
"""
|
|
35
|
+
encryption_context.set(context)
|