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.
Files changed (135) hide show
  1. langgraph_api/__init__.py +1 -1
  2. langgraph_api/api/__init__.py +111 -51
  3. langgraph_api/api/a2a.py +1610 -0
  4. langgraph_api/api/assistants.py +212 -89
  5. langgraph_api/api/mcp.py +3 -3
  6. langgraph_api/api/meta.py +52 -28
  7. langgraph_api/api/openapi.py +27 -17
  8. langgraph_api/api/profile.py +108 -0
  9. langgraph_api/api/runs.py +342 -195
  10. langgraph_api/api/store.py +19 -2
  11. langgraph_api/api/threads.py +209 -27
  12. langgraph_api/asgi_transport.py +14 -9
  13. langgraph_api/asyncio.py +14 -4
  14. langgraph_api/auth/custom.py +52 -37
  15. langgraph_api/auth/langsmith/backend.py +4 -3
  16. langgraph_api/auth/langsmith/client.py +13 -8
  17. langgraph_api/cli.py +230 -133
  18. langgraph_api/command.py +5 -3
  19. langgraph_api/config/__init__.py +532 -0
  20. langgraph_api/config/_parse.py +58 -0
  21. langgraph_api/config/schemas.py +431 -0
  22. langgraph_api/cron_scheduler.py +17 -1
  23. langgraph_api/encryption/__init__.py +15 -0
  24. langgraph_api/encryption/aes_json.py +158 -0
  25. langgraph_api/encryption/context.py +35 -0
  26. langgraph_api/encryption/custom.py +280 -0
  27. langgraph_api/encryption/middleware.py +632 -0
  28. langgraph_api/encryption/shared.py +63 -0
  29. langgraph_api/errors.py +12 -1
  30. langgraph_api/executor_entrypoint.py +11 -6
  31. langgraph_api/feature_flags.py +29 -0
  32. langgraph_api/graph.py +176 -76
  33. langgraph_api/grpc/client.py +313 -0
  34. langgraph_api/grpc/config_conversion.py +231 -0
  35. langgraph_api/grpc/generated/__init__.py +29 -0
  36. langgraph_api/grpc/generated/checkpointer_pb2.py +63 -0
  37. langgraph_api/grpc/generated/checkpointer_pb2.pyi +99 -0
  38. langgraph_api/grpc/generated/checkpointer_pb2_grpc.py +329 -0
  39. langgraph_api/grpc/generated/core_api_pb2.py +216 -0
  40. langgraph_api/grpc/generated/core_api_pb2.pyi +905 -0
  41. langgraph_api/grpc/generated/core_api_pb2_grpc.py +1621 -0
  42. langgraph_api/grpc/generated/engine_common_pb2.py +219 -0
  43. langgraph_api/grpc/generated/engine_common_pb2.pyi +722 -0
  44. langgraph_api/grpc/generated/engine_common_pb2_grpc.py +24 -0
  45. langgraph_api/grpc/generated/enum_cancel_run_action_pb2.py +37 -0
  46. langgraph_api/grpc/generated/enum_cancel_run_action_pb2.pyi +12 -0
  47. langgraph_api/grpc/generated/enum_cancel_run_action_pb2_grpc.py +24 -0
  48. langgraph_api/grpc/generated/enum_control_signal_pb2.py +37 -0
  49. langgraph_api/grpc/generated/enum_control_signal_pb2.pyi +16 -0
  50. langgraph_api/grpc/generated/enum_control_signal_pb2_grpc.py +24 -0
  51. langgraph_api/grpc/generated/enum_durability_pb2.py +37 -0
  52. langgraph_api/grpc/generated/enum_durability_pb2.pyi +16 -0
  53. langgraph_api/grpc/generated/enum_durability_pb2_grpc.py +24 -0
  54. langgraph_api/grpc/generated/enum_multitask_strategy_pb2.py +37 -0
  55. langgraph_api/grpc/generated/enum_multitask_strategy_pb2.pyi +16 -0
  56. langgraph_api/grpc/generated/enum_multitask_strategy_pb2_grpc.py +24 -0
  57. langgraph_api/grpc/generated/enum_run_status_pb2.py +37 -0
  58. langgraph_api/grpc/generated/enum_run_status_pb2.pyi +22 -0
  59. langgraph_api/grpc/generated/enum_run_status_pb2_grpc.py +24 -0
  60. langgraph_api/grpc/generated/enum_stream_mode_pb2.py +37 -0
  61. langgraph_api/grpc/generated/enum_stream_mode_pb2.pyi +28 -0
  62. langgraph_api/grpc/generated/enum_stream_mode_pb2_grpc.py +24 -0
  63. langgraph_api/grpc/generated/enum_thread_status_pb2.py +37 -0
  64. langgraph_api/grpc/generated/enum_thread_status_pb2.pyi +16 -0
  65. langgraph_api/grpc/generated/enum_thread_status_pb2_grpc.py +24 -0
  66. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.py +37 -0
  67. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.pyi +16 -0
  68. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2_grpc.py +24 -0
  69. langgraph_api/grpc/generated/errors_pb2.py +39 -0
  70. langgraph_api/grpc/generated/errors_pb2.pyi +21 -0
  71. langgraph_api/grpc/generated/errors_pb2_grpc.py +24 -0
  72. langgraph_api/grpc/ops/__init__.py +370 -0
  73. langgraph_api/grpc/ops/assistants.py +424 -0
  74. langgraph_api/grpc/ops/runs.py +792 -0
  75. langgraph_api/grpc/ops/threads.py +1013 -0
  76. langgraph_api/http.py +16 -5
  77. langgraph_api/http_metrics.py +15 -35
  78. langgraph_api/http_metrics_utils.py +38 -0
  79. langgraph_api/js/build.mts +1 -1
  80. langgraph_api/js/client.http.mts +13 -7
  81. langgraph_api/js/client.mts +2 -5
  82. langgraph_api/js/package.json +29 -28
  83. langgraph_api/js/remote.py +56 -30
  84. langgraph_api/js/src/graph.mts +20 -0
  85. langgraph_api/js/sse.py +2 -2
  86. langgraph_api/js/ui.py +1 -1
  87. langgraph_api/js/yarn.lock +1204 -1006
  88. langgraph_api/logging.py +29 -2
  89. langgraph_api/metadata.py +99 -28
  90. langgraph_api/middleware/http_logger.py +7 -2
  91. langgraph_api/middleware/private_network.py +7 -7
  92. langgraph_api/models/run.py +54 -93
  93. langgraph_api/otel_context.py +205 -0
  94. langgraph_api/patch.py +5 -3
  95. langgraph_api/queue_entrypoint.py +154 -65
  96. langgraph_api/route.py +47 -5
  97. langgraph_api/schema.py +88 -10
  98. langgraph_api/self_hosted_logs.py +124 -0
  99. langgraph_api/self_hosted_metrics.py +450 -0
  100. langgraph_api/serde.py +79 -37
  101. langgraph_api/server.py +138 -60
  102. langgraph_api/state.py +4 -3
  103. langgraph_api/store.py +25 -16
  104. langgraph_api/stream.py +80 -29
  105. langgraph_api/thread_ttl.py +31 -13
  106. langgraph_api/timing/__init__.py +25 -0
  107. langgraph_api/timing/profiler.py +200 -0
  108. langgraph_api/timing/timer.py +318 -0
  109. langgraph_api/utils/__init__.py +53 -8
  110. langgraph_api/utils/cache.py +47 -10
  111. langgraph_api/utils/config.py +2 -1
  112. langgraph_api/utils/errors.py +77 -0
  113. langgraph_api/utils/future.py +10 -6
  114. langgraph_api/utils/headers.py +76 -2
  115. langgraph_api/utils/retriable_client.py +74 -0
  116. langgraph_api/utils/stream_codec.py +315 -0
  117. langgraph_api/utils/uuids.py +29 -62
  118. langgraph_api/validation.py +9 -0
  119. langgraph_api/webhook.py +120 -6
  120. langgraph_api/worker.py +55 -24
  121. {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/METADATA +16 -8
  122. langgraph_api-0.7.3.dist-info/RECORD +168 -0
  123. {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/WHEEL +1 -1
  124. langgraph_runtime/__init__.py +1 -0
  125. langgraph_runtime/routes.py +11 -0
  126. logging.json +1 -3
  127. openapi.json +839 -478
  128. langgraph_api/config.py +0 -387
  129. langgraph_api/js/isolate-0x130008000-46649-46649-v8.log +0 -4430
  130. langgraph_api/js/isolate-0x138008000-44681-44681-v8.log +0 -4430
  131. langgraph_api/js/package-lock.json +0 -3308
  132. langgraph_api-0.4.1.dist-info/RECORD +0 -107
  133. /langgraph_api/{utils.py → grpc/__init__.py} +0 -0
  134. {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/entry_points.txt +0 -0
  135. {langgraph_api-0.4.1.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
+ """
@@ -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 = 5
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)