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.
Files changed (122) hide show
  1. langgraph_api/__init__.py +1 -1
  2. langgraph_api/api/__init__.py +93 -27
  3. langgraph_api/api/a2a.py +36 -32
  4. langgraph_api/api/assistants.py +114 -26
  5. langgraph_api/api/mcp.py +3 -3
  6. langgraph_api/api/meta.py +15 -2
  7. langgraph_api/api/openapi.py +27 -17
  8. langgraph_api/api/profile.py +108 -0
  9. langgraph_api/api/runs.py +114 -57
  10. langgraph_api/api/store.py +19 -2
  11. langgraph_api/api/threads.py +133 -10
  12. langgraph_api/asgi_transport.py +14 -9
  13. langgraph_api/auth/custom.py +23 -13
  14. langgraph_api/cli.py +86 -41
  15. langgraph_api/command.py +2 -2
  16. langgraph_api/config/__init__.py +532 -0
  17. langgraph_api/config/_parse.py +58 -0
  18. langgraph_api/config/schemas.py +431 -0
  19. langgraph_api/cron_scheduler.py +17 -1
  20. langgraph_api/encryption/__init__.py +15 -0
  21. langgraph_api/encryption/aes_json.py +158 -0
  22. langgraph_api/encryption/context.py +35 -0
  23. langgraph_api/encryption/custom.py +280 -0
  24. langgraph_api/encryption/middleware.py +632 -0
  25. langgraph_api/encryption/shared.py +63 -0
  26. langgraph_api/errors.py +12 -1
  27. langgraph_api/executor_entrypoint.py +11 -6
  28. langgraph_api/feature_flags.py +19 -0
  29. langgraph_api/graph.py +163 -64
  30. langgraph_api/{grpc_ops → grpc}/client.py +142 -12
  31. langgraph_api/{grpc_ops → grpc}/config_conversion.py +16 -10
  32. langgraph_api/grpc/generated/__init__.py +29 -0
  33. langgraph_api/grpc/generated/checkpointer_pb2.py +63 -0
  34. langgraph_api/grpc/generated/checkpointer_pb2.pyi +99 -0
  35. langgraph_api/grpc/generated/checkpointer_pb2_grpc.py +329 -0
  36. langgraph_api/grpc/generated/core_api_pb2.py +216 -0
  37. langgraph_api/{grpc_ops → grpc}/generated/core_api_pb2.pyi +292 -372
  38. langgraph_api/{grpc_ops → grpc}/generated/core_api_pb2_grpc.py +252 -31
  39. langgraph_api/grpc/generated/engine_common_pb2.py +219 -0
  40. langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2.pyi +178 -104
  41. langgraph_api/grpc/generated/enum_cancel_run_action_pb2.py +37 -0
  42. langgraph_api/grpc/generated/enum_cancel_run_action_pb2.pyi +12 -0
  43. langgraph_api/grpc/generated/enum_cancel_run_action_pb2_grpc.py +24 -0
  44. langgraph_api/grpc/generated/enum_control_signal_pb2.py +37 -0
  45. langgraph_api/grpc/generated/enum_control_signal_pb2.pyi +16 -0
  46. langgraph_api/grpc/generated/enum_control_signal_pb2_grpc.py +24 -0
  47. langgraph_api/grpc/generated/enum_durability_pb2.py +37 -0
  48. langgraph_api/grpc/generated/enum_durability_pb2.pyi +16 -0
  49. langgraph_api/grpc/generated/enum_durability_pb2_grpc.py +24 -0
  50. langgraph_api/grpc/generated/enum_multitask_strategy_pb2.py +37 -0
  51. langgraph_api/grpc/generated/enum_multitask_strategy_pb2.pyi +16 -0
  52. langgraph_api/grpc/generated/enum_multitask_strategy_pb2_grpc.py +24 -0
  53. langgraph_api/grpc/generated/enum_run_status_pb2.py +37 -0
  54. langgraph_api/grpc/generated/enum_run_status_pb2.pyi +22 -0
  55. langgraph_api/grpc/generated/enum_run_status_pb2_grpc.py +24 -0
  56. langgraph_api/grpc/generated/enum_stream_mode_pb2.py +37 -0
  57. langgraph_api/grpc/generated/enum_stream_mode_pb2.pyi +28 -0
  58. langgraph_api/grpc/generated/enum_stream_mode_pb2_grpc.py +24 -0
  59. langgraph_api/grpc/generated/enum_thread_status_pb2.py +37 -0
  60. langgraph_api/grpc/generated/enum_thread_status_pb2.pyi +16 -0
  61. langgraph_api/grpc/generated/enum_thread_status_pb2_grpc.py +24 -0
  62. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.py +37 -0
  63. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.pyi +16 -0
  64. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2_grpc.py +24 -0
  65. langgraph_api/grpc/generated/errors_pb2.py +39 -0
  66. langgraph_api/grpc/generated/errors_pb2.pyi +21 -0
  67. langgraph_api/grpc/generated/errors_pb2_grpc.py +24 -0
  68. langgraph_api/grpc/ops/__init__.py +370 -0
  69. langgraph_api/grpc/ops/assistants.py +424 -0
  70. langgraph_api/grpc/ops/runs.py +792 -0
  71. langgraph_api/grpc/ops/threads.py +1013 -0
  72. langgraph_api/http.py +16 -5
  73. langgraph_api/js/client.mts +1 -4
  74. langgraph_api/js/package.json +28 -27
  75. langgraph_api/js/remote.py +39 -17
  76. langgraph_api/js/sse.py +2 -2
  77. langgraph_api/js/ui.py +1 -1
  78. langgraph_api/js/yarn.lock +1139 -869
  79. langgraph_api/metadata.py +29 -3
  80. langgraph_api/middleware/http_logger.py +1 -1
  81. langgraph_api/middleware/private_network.py +7 -7
  82. langgraph_api/models/run.py +44 -26
  83. langgraph_api/otel_context.py +205 -0
  84. langgraph_api/patch.py +2 -2
  85. langgraph_api/queue_entrypoint.py +34 -35
  86. langgraph_api/route.py +33 -1
  87. langgraph_api/schema.py +84 -9
  88. langgraph_api/self_hosted_logs.py +2 -2
  89. langgraph_api/self_hosted_metrics.py +73 -3
  90. langgraph_api/serde.py +16 -4
  91. langgraph_api/server.py +33 -31
  92. langgraph_api/state.py +3 -2
  93. langgraph_api/store.py +25 -16
  94. langgraph_api/stream.py +20 -16
  95. langgraph_api/thread_ttl.py +28 -13
  96. langgraph_api/timing/__init__.py +25 -0
  97. langgraph_api/timing/profiler.py +200 -0
  98. langgraph_api/timing/timer.py +318 -0
  99. langgraph_api/utils/__init__.py +53 -8
  100. langgraph_api/utils/config.py +2 -1
  101. langgraph_api/utils/future.py +10 -6
  102. langgraph_api/utils/uuids.py +29 -62
  103. langgraph_api/validation.py +6 -0
  104. langgraph_api/webhook.py +120 -6
  105. langgraph_api/worker.py +54 -24
  106. {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/METADATA +8 -6
  107. langgraph_api-0.7.3.dist-info/RECORD +168 -0
  108. {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/WHEEL +1 -1
  109. langgraph_runtime/__init__.py +1 -0
  110. langgraph_runtime/routes.py +11 -0
  111. logging.json +1 -3
  112. openapi.json +635 -537
  113. langgraph_api/config.py +0 -523
  114. langgraph_api/grpc_ops/generated/__init__.py +0 -5
  115. langgraph_api/grpc_ops/generated/core_api_pb2.py +0 -275
  116. langgraph_api/grpc_ops/generated/engine_common_pb2.py +0 -194
  117. langgraph_api/grpc_ops/ops.py +0 -1045
  118. langgraph_api-0.5.4.dist-info/RECORD +0 -121
  119. /langgraph_api/{grpc_ops → grpc}/__init__.py +0 -0
  120. /langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2_grpc.py +0 -0
  121. {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/entry_points.txt +0 -0
  122. {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,632 @@
1
+ """Encryption/decryption middleware for API layer.
2
+
3
+ This module provides helpers to encrypt data before storing and decrypt
4
+ after retrieving, keeping encryption logic at the API layer.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import base64
11
+ from typing import TYPE_CHECKING, Any, cast
12
+
13
+ import orjson
14
+ import structlog
15
+ from starlette.authentication import BaseUser
16
+ from starlette.exceptions import HTTPException
17
+ from starlette.middleware.base import BaseHTTPMiddleware
18
+ from starlette.requests import Request # noqa: TC002
19
+
20
+ from langgraph_api.auth.noop import UnauthenticatedUser
21
+ from langgraph_api.config import LANGGRAPH_ENCRYPTION
22
+ from langgraph_api.encryption.aes_json import (
23
+ AesEncryptionInstance,
24
+ DecryptorMissingError,
25
+ is_aes_encryption_context,
26
+ )
27
+ from langgraph_api.encryption.context import (
28
+ get_encryption_context,
29
+ set_encryption_context,
30
+ )
31
+ from langgraph_api.encryption.custom import (
32
+ JsonEncryptionWrapper,
33
+ ModelType,
34
+ get_custom_encryption_instance,
35
+ )
36
+ from langgraph_api.encryption.shared import (
37
+ BLOB_ENCRYPTION_CONTEXT_KEY,
38
+ ENCRYPTION_CONTEXT_KEY,
39
+ get_encryption,
40
+ strip_encryption_metadata,
41
+ )
42
+ from langgraph_api.schema import NESTED_ENCRYPTED_SUBFIELDS
43
+ from langgraph_api.serde import Fragment, json_loads
44
+
45
+ if TYPE_CHECKING:
46
+ from collections.abc import Mapping, Sequence
47
+
48
+ # Only import EncryptionContext at module load if encryption is configured
49
+ # This avoids requiring langgraph-sdk>=0.2.14 for users who don't use encryption
50
+ if LANGGRAPH_ENCRYPTION:
51
+ from langgraph_sdk import EncryptionContext
52
+
53
+ logger = structlog.stdlib.get_logger(__name__)
54
+
55
+
56
+ def _serialize_user_for_encryption(user: BaseUser) -> dict[str, Any]:
57
+ """Serialize a BaseUser to a JSON-serializable dict for encryption.
58
+
59
+ Called by _prepare_data_for_encryption when langgraph_auth_user contains a
60
+ BaseUser that needs to be serialized before JSON encryption.
61
+
62
+ Args:
63
+ user: The BaseUser to serialize (ProxyUser, SimpleUser, or custom subclass)
64
+
65
+ Returns:
66
+ A JSON-serializable dict with user data
67
+ """
68
+ # If the auth function returns a pydantic object as the User object, we
69
+ # want to preserve the additional fields
70
+ if hasattr(user, "model_dump") and callable(user.model_dump):
71
+ return cast("dict[str, Any]", user.model_dump())
72
+
73
+ # Plain BaseUser subclasses - extract the required properties
74
+ return {
75
+ "identity": user.identity,
76
+ "is_authenticated": user.is_authenticated,
77
+ "display_name": user.display_name,
78
+ }
79
+
80
+
81
+ def _prepare_data_for_encryption(data: dict[str, Any]) -> dict[str, Any]:
82
+ """Prepare data dict for encryption by serializing non-JSON-serializable objects.
83
+
84
+ Specifically handles langgraph_auth_user which may contain BaseUser objects
85
+ that can't be JSON-serialized. Dicts pass through unchanged (already serializable).
86
+
87
+ Args:
88
+ data: The data dict to prepare
89
+
90
+ Returns:
91
+ A new dict with serialized values where needed
92
+ """
93
+ if "langgraph_auth_user" not in data:
94
+ return data
95
+
96
+ user = data["langgraph_auth_user"]
97
+ if isinstance(user, BaseUser):
98
+ data = dict(data) # shallow copy
99
+ if isinstance(user, UnauthenticatedUser):
100
+ data["langgraph_auth_user"] = None
101
+ else:
102
+ data["langgraph_auth_user"] = _serialize_user_for_encryption(user)
103
+
104
+ return data
105
+
106
+
107
+ def extract_encryption_context(request: Request) -> dict[str, Any]:
108
+ """Extract encryption context from X-Encryption-Context header.
109
+
110
+ Args:
111
+ request: The Starlette request object
112
+
113
+ Returns:
114
+ Encryption context dict, or empty dict if header not present
115
+
116
+ Raises:
117
+ HTTPException: 422 if header is present but malformed
118
+ """
119
+ header_value = request.headers.get("X-Encryption-Context")
120
+ if not header_value:
121
+ return {}
122
+
123
+ try:
124
+ decoded = base64.b64decode(header_value.encode())
125
+ context = orjson.loads(decoded)
126
+ if not isinstance(context, dict):
127
+ raise HTTPException(
128
+ status_code=422,
129
+ detail="Invalid X-Encryption-Context header: expected base64-encoded JSON object",
130
+ )
131
+ return context
132
+ except HTTPException:
133
+ raise
134
+ except Exception as e:
135
+ raise HTTPException(
136
+ status_code=422,
137
+ detail=f"Invalid X-Encryption-Context header: {e}",
138
+ ) from e
139
+
140
+
141
+ class EncryptionContextMiddleware(BaseHTTPMiddleware):
142
+ """Middleware to extract and set encryption context from request headers.
143
+
144
+ If a @encryption.context handler is registered, it is called after extracting
145
+ the initial context from the X-Encryption-Context header. The handler receives
146
+ the authenticated user and can derive encryption context from auth (e.g., JWT claims).
147
+ """
148
+
149
+ async def dispatch(self, request: Request, call_next):
150
+ context_dict = extract_encryption_context(request)
151
+
152
+ # Call context handler if registered (to derive context from auth)
153
+ encryption_instance = get_custom_encryption_instance()
154
+ if encryption_instance and encryption_instance._context_handler:
155
+ user = request.scope.get("user")
156
+ if user:
157
+ initial_ctx = EncryptionContext(
158
+ model=None, field=None, metadata=context_dict
159
+ )
160
+ try:
161
+ context_dict = await encryption_instance._context_handler(
162
+ user, initial_ctx
163
+ )
164
+ except Exception as e:
165
+ await logger.aexception(
166
+ "Error in encryption context handler", exc_info=e
167
+ )
168
+
169
+ set_encryption_context(context_dict)
170
+ request.state.encryption_context = context_dict
171
+ response = await call_next(request)
172
+ return response
173
+
174
+
175
+ class DoubleEncryptionError(Exception):
176
+ """Raised when attempting to encrypt data that is already encrypted.
177
+
178
+ This typically indicates a bug where encrypted data is being passed through
179
+ the encryption pipeline again, which would corrupt the data.
180
+ """
181
+
182
+
183
+ async def encrypt_json_if_needed(
184
+ data: dict[str, Any] | None,
185
+ encryption_instance: JsonEncryptionWrapper | AesEncryptionInstance | None,
186
+ model_type: ModelType,
187
+ field: str | None = None,
188
+ ) -> dict[str, Any] | None:
189
+ """Encrypt JSON data dict if encryption is configured.
190
+
191
+ Uses a unified interface where both custom (via wrapper) and AES encryption
192
+ implement the same encryptor interface (get_json_encryptor). The wrapper
193
+ handles key validation and context storage internally.
194
+
195
+ Args:
196
+ data: The plaintext data dict
197
+ encryption_instance: The encryption instance (wrapped custom, AES, or None)
198
+ model_type: The type of model (e.g., "thread", "assistant", "run")
199
+ field: The specific field being encrypted (e.g., "metadata", "context")
200
+
201
+ Returns:
202
+ Encrypted data dict with stored context, or original if no encryption configured
203
+
204
+ Raises:
205
+ EncryptionKeyError: If the encryptor adds or removes keys (violates key preservation)
206
+ DoubleEncryptionError: If data already has encryption context marker (already encrypted)
207
+ """
208
+ if data is None:
209
+ return data
210
+
211
+ # Early return if no encryption configured (avoid unnecessary context lookups)
212
+ if encryption_instance is None:
213
+ return data
214
+
215
+ # Safety check: detect if data is already encrypted to prevent double encryption.
216
+ # Both AES and custom encryption use __encryption_context__ marker.
217
+ if ENCRYPTION_CONTEXT_KEY in data:
218
+ raise DoubleEncryptionError(
219
+ f"Attempted to encrypt data that is already encrypted (has {ENCRYPTION_CONTEXT_KEY}). "
220
+ f"model_type={model_type}, field={field}. "
221
+ f"This indicates a bug where encrypted data is being re-encrypted. "
222
+ f"Ensure data is decrypted before re-encrypting."
223
+ )
224
+
225
+ # Get encryptor from the instance (works for both custom wrapper and AES)
226
+ encryptor = encryption_instance.get_json_encryptor(model_type)
227
+ if encryptor is not None:
228
+ # Prepare data for encryption by serializing non-JSON-serializable objects
229
+ # (e.g., BaseUser in langgraph_auth_user)
230
+ data = _prepare_data_for_encryption(data)
231
+
232
+ # Build context for SDK interface (AES ignores this, custom uses it)
233
+ context_dict = get_encryption_context()
234
+ if LANGGRAPH_ENCRYPTION:
235
+ ctx = EncryptionContext(
236
+ model=model_type, field=field, metadata=context_dict
237
+ )
238
+ else:
239
+ ctx = None # AES doesn't need the EncryptionContext
240
+
241
+ # The encryptor handles key validation and context storage internally
242
+ encrypted = await encryptor(ctx, data)
243
+
244
+ await logger.adebug(
245
+ "Encrypted JSON data",
246
+ model_type=model_type,
247
+ field=field,
248
+ encryption_type="aes_only"
249
+ if isinstance(encryption_instance, AesEncryptionInstance)
250
+ else "custom_with_aes_migration",
251
+ )
252
+ return encrypted
253
+
254
+ # No JSON encryption configured, but store context for blob encryption
255
+ # This allows the worker to extract context even when JSON encryption is disabled
256
+ context_dict = get_encryption_context()
257
+ if context_dict and isinstance(data, dict):
258
+ data = dict(data) # shallow copy to avoid mutating input
259
+ data[BLOB_ENCRYPTION_CONTEXT_KEY] = context_dict
260
+ return data
261
+
262
+
263
+ def extract_blob_encryption_context(
264
+ data: dict[str, Any] | None,
265
+ ) -> dict[str, Any] | None:
266
+ """Extract blob encryption context from a data dict.
267
+
268
+ This is used by the worker to extract the encryption context needed for
269
+ blob encryption during checkpoint serialization.
270
+
271
+ Checks both __blob_encryption_context__ (for blob-only encryption) and
272
+ __encryption_context__ (from JSON encryption, backward compatibility).
273
+
274
+ Args:
275
+ data: The data dict that may contain an encryption context
276
+
277
+ Returns:
278
+ The parsed encryption context dict, or None if not present
279
+ """
280
+ if data is None:
281
+ return None
282
+
283
+ # Prefer __blob_encryption_context__ (explicit blob context)
284
+ # Fall back to __encryption_context__ (from JSON encryption, backward compat)
285
+ return data.get(BLOB_ENCRYPTION_CONTEXT_KEY) or data.get(ENCRYPTION_CONTEXT_KEY)
286
+
287
+
288
+ async def decrypt_json_if_needed(
289
+ data: dict[str, Any] | None,
290
+ encryption_instance: JsonEncryptionWrapper | AesEncryptionInstance | None,
291
+ model_type: ModelType,
292
+ field: str | None = None,
293
+ ) -> dict[str, Any] | None:
294
+ """Decrypt JSON data dict based on encryption markers.
295
+
296
+ Routes to the appropriate decryptor based on the type in __encryption_context__:
297
+ 1. If marker present with AES type → use AES decryptor
298
+ 2. If marker present without AES type → use custom decryptor
299
+ 3. No marker → plaintext (return unchanged)
300
+
301
+ The routing logic is handled internally by the wrapper/instance:
302
+ - JsonEncryptionWrapper handles routing for custom + AES migration
303
+ - AesEncryptionInstance handles AES-only decryption
304
+
305
+ Args:
306
+ data: The data dict (encrypted or plaintext)
307
+ encryption_instance: The effective encryption instance (wrapper or AES-only)
308
+ model_type: The type of model (e.g., "thread", "assistant", "run")
309
+ field: The specific field being decrypted (e.g., "metadata", "context")
310
+
311
+ Returns:
312
+ Decrypted data dict (without reserved keys), or original if not encrypted
313
+
314
+ Raises:
315
+ DecryptorMissingError: If data is encrypted but required decryptor is not configured
316
+ EncryptionRoutingError: If markers are inconsistent (e.g., AES values with custom marker)
317
+ """
318
+ if data is None:
319
+ return data
320
+
321
+ # No encryption configured
322
+ if encryption_instance is None:
323
+ # If data has encryption marker but no decryptor, raise error
324
+ if ENCRYPTION_CONTEXT_KEY in data:
325
+ context_dict = data[ENCRYPTION_CONTEXT_KEY]
326
+ if is_aes_encryption_context(context_dict):
327
+ raise DecryptorMissingError(
328
+ f"Data has AES encryption marker but LANGGRAPH_AES_KEY is not configured "
329
+ f"for {model_type}.{field}"
330
+ )
331
+ raise DecryptorMissingError(
332
+ f"Data contains custom encryption marker but no encryption instance is configured "
333
+ f"for {model_type}.{field}"
334
+ )
335
+ return strip_encryption_metadata(data)
336
+
337
+ # Get decryptor - the wrapper/instance handles routing (AES/custom/plaintext) internally
338
+ decryptor = encryption_instance.get_json_decryptor(model_type)
339
+
340
+ # Build context for SDK interface (AES ignores this, custom uses it)
341
+ context_dict = data.get(ENCRYPTION_CONTEXT_KEY, {})
342
+ if LANGGRAPH_ENCRYPTION:
343
+ ctx = EncryptionContext(model=model_type, field=field, metadata=context_dict)
344
+ else:
345
+ ctx = None
346
+
347
+ decrypted = await decryptor(ctx, data)
348
+
349
+ await logger.adebug(
350
+ "Decrypted JSON data",
351
+ model_type=model_type,
352
+ field=field,
353
+ encryption_type="aes_only"
354
+ if isinstance(encryption_instance, AesEncryptionInstance)
355
+ else "custom_with_aes_migration",
356
+ )
357
+ return decrypted
358
+
359
+
360
+ async def _decrypt_field(
361
+ obj: dict[str, Any],
362
+ field_name: str,
363
+ encryption_instance: JsonEncryptionWrapper | AesEncryptionInstance | None,
364
+ model_type: ModelType,
365
+ ) -> tuple[str, Any]:
366
+ """Decrypt a single field, returning (field_name, decrypted_value).
367
+
368
+ Fields defined in NESTED_ENCRYPTED_SUBFIELDS have their subfields decrypted
369
+ recursively (e.g., run.kwargs.config.configurable).
370
+
371
+ Returns (field_name, None) if field doesn't exist or is falsy.
372
+ """
373
+ if not obj.get(field_name):
374
+ return (field_name, obj.get(field_name))
375
+
376
+ value = obj[field_name]
377
+ # Database fields come back as either:
378
+ # - dict: already parsed JSONB (psycopg JSON adapter)
379
+ # - bytes/bytearray/memoryview/str: raw JSON to parse (psycopg binary mode)
380
+ # - Fragment: wrapper around bytes (used by serde layer)
381
+ if isinstance(value, dict):
382
+ pass # already parsed
383
+ elif isinstance(value, (bytes, bytearray, memoryview, str, Fragment)):
384
+ value = json_loads(value)
385
+ else:
386
+ raise TypeError(
387
+ f"Cannot decrypt field '{field_name}': expected dict or JSON-serialized "
388
+ f"bytes/str, got {type(value).__name__}"
389
+ )
390
+
391
+ decrypted = await decrypt_json_if_needed(
392
+ value, encryption_instance, model_type, field=field_name
393
+ )
394
+
395
+ # Recursively decrypt subfields defined in NESTED_ENCRYPTED_SUBFIELDS.
396
+ # This handles nested structures like run.kwargs.config.configurable where each
397
+ # level needs individual encryption to preserve structure for SQL JSONB operations.
398
+ nested_key = (model_type, field_name)
399
+ if nested_key in NESTED_ENCRYPTED_SUBFIELDS and decrypted is not None:
400
+ results = await asyncio.gather(
401
+ *[
402
+ _decrypt_field(decrypted, sf, encryption_instance, model_type)
403
+ for sf in NESTED_ENCRYPTED_SUBFIELDS[nested_key]
404
+ if sf in decrypted
405
+ ]
406
+ )
407
+ for sf_name, sf_value in results:
408
+ decrypted[sf_name] = sf_value
409
+
410
+ return (field_name, decrypted)
411
+
412
+
413
+ async def _decrypt_object(
414
+ obj: dict[str, Any],
415
+ model_type: ModelType,
416
+ fields: list[str],
417
+ encryption_instance: JsonEncryptionWrapper | AesEncryptionInstance | None,
418
+ ) -> None:
419
+ """Decrypt all specified fields in a single object (in parallel).
420
+
421
+ Only processes fields that exist in the object to avoid adding new fields.
422
+ """
423
+ results = await asyncio.gather(
424
+ *[
425
+ _decrypt_field(obj, f, encryption_instance, model_type)
426
+ for f in fields
427
+ if f in obj
428
+ ]
429
+ )
430
+ for field_name, value in results:
431
+ obj[field_name] = value
432
+
433
+
434
+ async def decrypt_response(
435
+ obj: Mapping[str, Any],
436
+ model_type: ModelType,
437
+ fields: list[str],
438
+ ) -> dict[str, Any]:
439
+ """Decrypt specified fields in a response object (from database).
440
+
441
+ IMPORTANT: This function only parses and decrypts fields when encryption is
442
+ enabled (custom or AES). When encryption is disabled, the original object is
443
+ returned as-is (no copy, no parsing). This is intentional: some fields can be
444
+ very large, and we want to avoid parsing overhead when the bytes can be passed
445
+ through directly to the response. Callers that need parsed dicts regardless of
446
+ encryption state should use json_loads() on the fields they need to inspect.
447
+
448
+ When encryption IS enabled, this parses bytes/memoryview/Fragment to dicts
449
+ before decryption, and returns a shallow copy with decrypted fields.
450
+
451
+ Fields defined in NESTED_ENCRYPTED_SUBFIELDS have their subfields decrypted
452
+ recursively (e.g., config.configurable, config.metadata).
453
+
454
+ Args:
455
+ obj: Single mapping from database (fields may be bytes or already-parsed dicts, not mutated)
456
+ model_type: Type identifier passed to EncryptionContext.model (e.g., "run", "cron", "thread")
457
+ fields: List of field names to decrypt (e.g., ["metadata", "kwargs"])
458
+
459
+ Returns:
460
+ Original object if encryption disabled, otherwise new dict with decrypted fields
461
+ """
462
+ encryption_instance = get_encryption()
463
+ if encryption_instance is None:
464
+ return obj # type: ignore[return-value]
465
+
466
+ result = dict(obj)
467
+ await _decrypt_object(result, model_type, fields, encryption_instance)
468
+ return result
469
+
470
+
471
+ async def decrypt_responses(
472
+ objects: Sequence[Mapping[str, Any]],
473
+ model_type: ModelType,
474
+ fields: list[str],
475
+ ) -> list[dict[str, Any]]:
476
+ """Decrypt specified fields in multiple response objects (from database).
477
+
478
+ IMPORTANT: This function only parses and decrypts fields when encryption is
479
+ enabled (custom or AES). When encryption is disabled, the original sequence is
480
+ returned as-is (no copies, no parsing). This is intentional: some fields can be
481
+ very large, and we want to avoid parsing overhead when the bytes can be passed
482
+ through directly to the response. Callers that need parsed dicts regardless of
483
+ encryption state should use json_loads() on the fields they need to inspect.
484
+
485
+ When encryption IS enabled, this parses bytes/memoryview/Fragment to dicts
486
+ before decryption, and returns a new list of shallow copies with decrypted fields.
487
+
488
+ Fields defined in NESTED_ENCRYPTED_SUBFIELDS have their subfields decrypted
489
+ recursively (e.g., config.configurable, config.metadata).
490
+
491
+ Args:
492
+ objects: Sequence of mappings from database (fields may be bytes or already-parsed dicts, not mutated)
493
+ model_type: Type identifier passed to EncryptionContext.model (e.g., "run", "cron", "thread")
494
+ fields: List of field names to decrypt (e.g., ["metadata", "kwargs"])
495
+
496
+ Returns:
497
+ Original sequence if encryption disabled, otherwise new list with decrypted fields
498
+ """
499
+ encryption_instance = get_encryption()
500
+ if encryption_instance is None:
501
+ return objects # type: ignore[return-value]
502
+
503
+ results = [dict(obj) for obj in objects]
504
+ await asyncio.gather(
505
+ *[
506
+ _decrypt_object(result, model_type, fields, encryption_instance)
507
+ for result in results
508
+ ]
509
+ )
510
+ return results
511
+
512
+
513
+ async def _encrypt_field(
514
+ data: Mapping[str, Any],
515
+ field_name: str,
516
+ encryption_instance: JsonEncryptionWrapper | AesEncryptionInstance | None,
517
+ model_type: ModelType,
518
+ ) -> tuple[str, Any]:
519
+ """Encrypt a single field, returning (field_name, encrypted_value).
520
+
521
+ Fields defined in NESTED_ENCRYPTED_SUBFIELDS have their subfields extracted
522
+ and encrypted separately, then added back. This preserves the nested structure
523
+ for SQL JSONB operations while encrypting each level individually.
524
+
525
+ Returns (field_name, None) if field doesn't exist or is None.
526
+ """
527
+ if field_name not in data or data[field_name] is None:
528
+ return (field_name, data.get(field_name))
529
+
530
+ field_data = data[field_name]
531
+
532
+ # Check if this field has subfields that need separate encryption
533
+ nested_key = (model_type, field_name)
534
+ subfields_to_extract: dict[str, Any] = {}
535
+
536
+ if nested_key in NESTED_ENCRYPTED_SUBFIELDS:
537
+ if not isinstance(field_data, dict):
538
+ raise TypeError(
539
+ f"'{field_name}' must be a dict for encryption, got {type(field_data).__name__}"
540
+ )
541
+ for subfield in NESTED_ENCRYPTED_SUBFIELDS[nested_key]:
542
+ subfield_value = field_data.get(subfield)
543
+ if subfield_value is not None and not isinstance(subfield_value, dict):
544
+ raise TypeError(
545
+ f"'{subfield}' in '{field_name}' must be a dict for encryption, "
546
+ f"got {type(subfield_value).__name__}"
547
+ )
548
+ if subfield_value:
549
+ subfields_to_extract[subfield] = subfield_value
550
+
551
+ if subfields_to_extract:
552
+ # Create a copy without subfields for the first encryption pass
553
+ field_data = {
554
+ k: v for k, v in field_data.items() if k not in subfields_to_extract
555
+ }
556
+
557
+ encrypted = await encrypt_json_if_needed(
558
+ field_data,
559
+ encryption_instance,
560
+ model_type,
561
+ field=field_name,
562
+ )
563
+
564
+ # Recursively encrypt extracted subfields and add them back
565
+ if subfields_to_extract and isinstance(encrypted, dict):
566
+ subfield_results = await asyncio.gather(
567
+ *[
568
+ _encrypt_field(
569
+ {sf_name: sf_value},
570
+ sf_name,
571
+ encryption_instance,
572
+ model_type,
573
+ )
574
+ for sf_name, sf_value in subfields_to_extract.items()
575
+ ]
576
+ )
577
+ for sf_name, sf_encrypted in subfield_results:
578
+ encrypted[sf_name] = sf_encrypted
579
+
580
+ return (field_name, encrypted)
581
+
582
+
583
+ async def encrypt_request(
584
+ data: Mapping[str, Any],
585
+ model_type: ModelType,
586
+ fields: list[str],
587
+ ) -> dict[str, Any]:
588
+ """Encrypt specified fields in request data before passing to ops layer (in parallel).
589
+
590
+ This is a generic helper that handles encryption for any object type.
591
+ It uses the ContextVar to get encryption context (set by middleware or endpoint).
592
+
593
+ When encryption is disabled (neither custom nor AES), the original data is
594
+ returned as-is (no copy). When encryption IS enabled, returns a shallow copy
595
+ with encrypted fields.
596
+
597
+ Fields defined in NESTED_ENCRYPTED_SUBFIELDS have their subfields encrypted
598
+ recursively (e.g., config.configurable, config.metadata).
599
+
600
+ Only processes fields that exist in the data to avoid adding new fields.
601
+
602
+ Args:
603
+ data: Request data mapping to encrypt (not mutated)
604
+ model_type: Type identifier passed to EncryptionContext.model (e.g., "run", "cron", "thread")
605
+ fields: List of field names to encrypt (e.g., ["metadata", "kwargs"])
606
+
607
+ Returns:
608
+ Original data if encryption disabled, otherwise new dict with encrypted fields
609
+
610
+ Example:
611
+ encrypted = await encrypt_request(
612
+ payload,
613
+ "run",
614
+ ["metadata"]
615
+ )
616
+ """
617
+ encryption_instance = get_encryption()
618
+ if encryption_instance is None:
619
+ return data # type: ignore[return-value]
620
+
621
+ result = dict(data)
622
+ encrypted_fields = await asyncio.gather(
623
+ *[
624
+ _encrypt_field(data, f, encryption_instance, model_type)
625
+ for f in fields
626
+ if f in data
627
+ ]
628
+ )
629
+ for field_name, value in encrypted_fields:
630
+ result[field_name] = value
631
+
632
+ return result
@@ -0,0 +1,63 @@
1
+ """Shared encryption constants and utilities.
2
+
3
+ This module contains constants and helper functions used by both
4
+ custom encryption (via SDK) and built-in AES encryption.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import functools
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from langgraph_api.encryption.aes_json import AesEncryptionInstance
14
+ from langgraph_api.encryption.custom import JsonEncryptionWrapper
15
+
16
+ # Marker keys for encryption context storage
17
+ ENCRYPTION_CONTEXT_KEY = "__encryption_context__"
18
+ BLOB_ENCRYPTION_CONTEXT_KEY = "__blob_encryption_context__"
19
+
20
+ # Reserved keys that should never appear in user-facing responses
21
+ RESERVED_ENCRYPTION_KEYS = frozenset(
22
+ {ENCRYPTION_CONTEXT_KEY, BLOB_ENCRYPTION_CONTEXT_KEY}
23
+ )
24
+
25
+
26
+ def strip_encryption_metadata(data: dict[str, Any]) -> dict[str, Any]:
27
+ """Strip encryption-related keys from a data dict.
28
+
29
+ Used during decryption to remove internal markers before returning
30
+ data to callers.
31
+
32
+ Args:
33
+ data: Dict that may contain encryption marker keys
34
+
35
+ Returns:
36
+ New dict with marker keys removed
37
+ """
38
+ return {k: v for k, v in data.items() if k not in RESERVED_ENCRYPTION_KEYS}
39
+
40
+
41
+ @functools.lru_cache(maxsize=1)
42
+ def get_encryption() -> JsonEncryptionWrapper | AesEncryptionInstance | None:
43
+ """Get the effective encryption instance for JSON encryption.
44
+
45
+ Returns the cached encryption instance based on configuration:
46
+ - Custom + AES configured: JsonEncryptionWrapper (handles migration)
47
+ - AES only: AesEncryptionInstance
48
+ - Neither: None
49
+ """
50
+ # Late import to avoid circular dependency
51
+ from langgraph_api.encryption.aes_json import get_aes_encryption_instance
52
+ from langgraph_api.encryption.custom import (
53
+ JsonEncryptionWrapper,
54
+ get_custom_encryption_instance,
55
+ )
56
+
57
+ custom_instance = get_custom_encryption_instance()
58
+ aes = get_aes_encryption_instance()
59
+
60
+ if custom_instance:
61
+ # Wrap custom encryption with AES migration support (can decrypt old AES data)
62
+ return JsonEncryptionWrapper(custom_instance, aes)
63
+ return aes