langgraph-sdk 0.3.0__tar.gz → 0.3.2__tar.gz

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 (29) hide show
  1. langgraph_sdk-0.3.2/.gitignore +102 -0
  2. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/PKG-INFO +1 -1
  3. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/langgraph_sdk/__init__.py +1 -1
  4. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/langgraph_sdk/client.py +12 -0
  5. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/langgraph_sdk/encryption/__init__.py +137 -225
  6. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/langgraph_sdk/schema.py +2 -0
  7. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/tests/test_encryption.py +0 -20
  8. langgraph_sdk-0.3.0/.gitignore +0 -184
  9. langgraph_sdk-0.3.0/tests/test_select_fields_sync.py +0 -76
  10. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/LICENSE +0 -0
  11. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/Makefile +0 -0
  12. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/README.md +0 -0
  13. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/langgraph_sdk/auth/__init__.py +0 -0
  14. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/langgraph_sdk/auth/exceptions.py +0 -0
  15. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/langgraph_sdk/auth/types.py +0 -0
  16. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/langgraph_sdk/encryption/types.py +0 -0
  17. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/langgraph_sdk/errors.py +0 -0
  18. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/langgraph_sdk/py.typed +0 -0
  19. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/langgraph_sdk/sse.py +0 -0
  20. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/pyproject.toml +0 -0
  21. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/tests/fixtures/response.txt +0 -0
  22. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/tests/test_api_parity.py +0 -0
  23. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/tests/test_assistants_client.py +0 -0
  24. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/tests/test_client_stream.py +0 -0
  25. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/tests/test_errors.py +0 -0
  26. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/tests/test_serde.py +0 -0
  27. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/tests/test_serde_schema.py +0 -0
  28. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/tests/test_skip_auto_load_api_key.py +0 -0
  29. {langgraph_sdk-0.3.0 → langgraph_sdk-0.3.2}/uv.lock +0 -0
@@ -0,0 +1,102 @@
1
+ .vs/
2
+ .vscode/
3
+ .idea/
4
+ # Byte-compiled / optimized / DLL files
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ pip-wheel-metadata/
24
+ share/python-wheels/
25
+ *.egg-info/
26
+ .installed.cfg
27
+ *.egg
28
+ MANIFEST
29
+
30
+ # PyInstaller
31
+ # Usually these files are written by a python script from a template
32
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
33
+ *.manifest
34
+ *.spec
35
+
36
+ # Installer logs
37
+ pip-log.txt
38
+ pip-delete-this-directory.txt
39
+
40
+ # Unit test / coverage reports
41
+ htmlcov/
42
+ .tox/
43
+ .nox/
44
+ .coverage
45
+ .coverage.*
46
+ .cache
47
+ nosetests.xml
48
+ coverage.xml
49
+ *.cover
50
+ *.py,cover
51
+ .hypothesis/
52
+ .pytest_cache/
53
+
54
+ # Django stuff:
55
+ *.log
56
+ local_settings.py
57
+ db.sqlite3
58
+ db.sqlite3-journal
59
+
60
+ # PyBuilder
61
+ target/
62
+
63
+ # Jupyter Notebook
64
+ .ipynb_checkpoints
65
+ notebooks/
66
+
67
+ # IPython
68
+ profile_default/
69
+ ipython_config.py
70
+
71
+ # pyenv
72
+ .python-version
73
+
74
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
75
+ __pypackages__/
76
+
77
+ # Environments
78
+ .env
79
+ .envrc
80
+ .venv
81
+ .venvs
82
+ env/
83
+ venv/
84
+ ENV/
85
+ env.bak/
86
+ venv.bak/
87
+
88
+ # mypy
89
+ .mypy_cache/
90
+ .dmypy.json
91
+ dmypy.json
92
+
93
+ # Pyre type checker
94
+ .pyre/
95
+
96
+ # macOS display setting files
97
+ .DS_Store
98
+
99
+ .vercel
100
+ .turbo
101
+ .editorconfig
102
+ .scratch
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-sdk
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: SDK for interacting with LangGraph API
5
5
  Project-URL: Source, https://github.com/langchain-ai/langgraph/tree/main/libs/sdk-py
6
6
  Project-URL: Twitter, https://x.com/LangChainAI
@@ -3,6 +3,6 @@ from langgraph_sdk.client import get_client, get_sync_client
3
3
  from langgraph_sdk.encryption import Encryption
4
4
  from langgraph_sdk.encryption.types import EncryptionContext
5
5
 
6
- __version__ = "0.3.0"
6
+ __version__ = "0.3.2"
7
7
 
8
8
  __all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]
@@ -3064,6 +3064,7 @@ class CronClient:
3064
3064
  interrupt_before: All | list[str] | None = None,
3065
3065
  interrupt_after: All | list[str] | None = None,
3066
3066
  webhook: str | None = None,
3067
+ on_run_completed: OnCompletionBehavior | None = None,
3067
3068
  multitask_strategy: str | None = None,
3068
3069
  headers: Mapping[str, str] | None = None,
3069
3070
  params: QueryParamTypes | None = None,
@@ -3083,6 +3084,10 @@ class CronClient:
3083
3084
  interrupt_before: Nodes to interrupt immediately before they get executed.
3084
3085
  interrupt_after: Nodes to Nodes to interrupt immediately after they get executed.
3085
3086
  webhook: Webhook to call after LangGraph API call is done.
3087
+ on_run_completed: What to do with the thread after the run completes.
3088
+ Must be one of 'delete' (default) or 'keep'. 'delete' removes the thread
3089
+ after execution. 'keep' creates a new thread for each execution but does not
3090
+ clean them up. Clients are responsible for cleaning up kept threads.
3086
3091
  multitask_strategy: Multitask strategy to use.
3087
3092
  Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
3088
3093
  headers: Optional custom headers to include with the request.
@@ -3120,6 +3125,7 @@ class CronClient:
3120
3125
  "interrupt_before": interrupt_before,
3121
3126
  "interrupt_after": interrupt_after,
3122
3127
  "webhook": webhook,
3128
+ "on_run_completed": on_run_completed,
3123
3129
  }
3124
3130
  if multitask_strategy:
3125
3131
  payload["multitask_strategy"] = multitask_strategy
@@ -6356,6 +6362,7 @@ class SyncCronClient:
6356
6362
  interrupt_before: All | list[str] | None = None,
6357
6363
  interrupt_after: All | list[str] | None = None,
6358
6364
  webhook: str | None = None,
6365
+ on_run_completed: OnCompletionBehavior | None = None,
6359
6366
  multitask_strategy: str | None = None,
6360
6367
  headers: Mapping[str, str] | None = None,
6361
6368
  params: QueryParamTypes | None = None,
@@ -6375,6 +6382,10 @@ class SyncCronClient:
6375
6382
  interrupt_before: Nodes to interrupt immediately before they get executed.
6376
6383
  interrupt_after: Nodes to Nodes to interrupt immediately after they get executed.
6377
6384
  webhook: Webhook to call after LangGraph API call is done.
6385
+ on_run_completed: What to do with the thread after the run completes.
6386
+ Must be one of 'delete' (default) or 'keep'. 'delete' removes the thread
6387
+ after execution. 'keep' creates a new thread for each execution but does not
6388
+ clean them up. Clients are responsible for cleaning up kept threads.
6378
6389
  multitask_strategy: Multitask strategy to use.
6379
6390
  Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
6380
6391
  headers: Optional custom headers to include with the request.
@@ -6412,6 +6423,7 @@ class SyncCronClient:
6412
6423
  "interrupt_after": interrupt_after,
6413
6424
  "webhook": webhook,
6414
6425
  "checkpoint_during": checkpoint_during,
6426
+ "on_run_completed": on_run_completed,
6415
6427
  "multitask_strategy": multitask_strategy,
6416
6428
  }
6417
6429
  payload = {k: v for k, v in payload.items() if v is not None}
@@ -64,114 +64,6 @@ def _validate_handler(fn: typing.Callable, handler_type: str) -> None:
64
64
  )
65
65
 
66
66
 
67
- class _JsonEncryptDecorators:
68
- """Dynamic decorator factory for JSON encryption handlers.
69
-
70
- Supports both default and model-specific handlers:
71
- - @encrypt.json - default handler for all models
72
- - @encrypt.json.thread - handler for thread model
73
- """
74
-
75
- def __init__(self, parent: Encryption):
76
- self._parent = parent
77
-
78
- def __call__(self, fn: types.JsonEncryptor) -> types.JsonEncryptor:
79
- """Register the default JSON encryption handler.
80
-
81
- Args:
82
- fn: The handler function
83
-
84
- Returns:
85
- The registered handler function
86
-
87
- Raises:
88
- DuplicateHandlerError: If handler already registered
89
- TypeError: If handler has invalid signature
90
- """
91
- if self._parent._json_encryptor is not None:
92
- raise DuplicateHandlerError("Default JSON encryptor already registered")
93
- _validate_handler(fn, "Default JSON encryptor")
94
- self._parent._json_encryptor = fn
95
- return fn
96
-
97
- def __getattr__(
98
- self, model: str
99
- ) -> typing.Callable[[types.JsonEncryptor], types.JsonEncryptor]:
100
- """Dynamic attribute access for model-specific handlers.
101
-
102
- Allows @encryption.encrypt.json.thread, @encryption.encrypt.json.assistant, etc.
103
-
104
- Raises:
105
- DuplicateHandlerError: If handler already registered for this model
106
- TypeError: If handler has invalid signature
107
- """
108
-
109
- def decorator(fn: types.JsonEncryptor) -> types.JsonEncryptor:
110
- if model in self._parent._json_encryptors:
111
- raise DuplicateHandlerError(
112
- f"JSON encryptor for model '{model}' already registered"
113
- )
114
- _validate_handler(fn, f"JSON encryptor for model '{model}'")
115
- self._parent._json_encryptors[model] = fn
116
- return fn
117
-
118
- return decorator
119
-
120
-
121
- class _JsonDecryptDecorators:
122
- """Dynamic decorator factory for JSON decryption handlers.
123
-
124
- Supports both default and model-specific handlers:
125
- - @encryption.decrypt.json - default handler for all models
126
- - @encryption.decrypt.json.thread - handler for thread model
127
- """
128
-
129
- def __init__(self, parent: Encryption):
130
- self._parent = parent
131
-
132
- def __call__(self, fn: types.JsonDecryptor) -> types.JsonDecryptor:
133
- """Register the default JSON decryption handler.
134
-
135
- Args:
136
- fn: The handler function
137
-
138
- Returns:
139
- The registered handler function
140
-
141
- Raises:
142
- DuplicateHandlerError: If handler already registered
143
- TypeError: If handler has invalid signature
144
- """
145
- if self._parent._json_decryptor is not None:
146
- raise DuplicateHandlerError("Default JSON decryptor already registered")
147
- _validate_handler(fn, "Default JSON decryptor")
148
- self._parent._json_decryptor = fn
149
- return fn
150
-
151
- def __getattr__(
152
- self, model: str
153
- ) -> typing.Callable[[types.JsonDecryptor], types.JsonDecryptor]:
154
- """Dynamic attribute access for model-specific handlers.
155
-
156
- Allows @encryption.decrypt.json.thread, @encryption.decrypt.json.assistant, etc.
157
-
158
- Raises:
159
- DuplicateHandlerError: If handler already registered for this model
160
- TypeError: If handler has invalid signature
161
- """
162
-
163
- def decorator(fn: types.JsonDecryptor) -> types.JsonDecryptor:
164
- if model in self._parent._json_decryptors:
165
- raise DuplicateHandlerError(
166
- f"JSON decryptor for model '{model}' already registered"
167
- )
168
- _validate_handler(fn, f"JSON decryptor for model '{model}'")
169
- self._parent._json_decryptors[model] = fn
170
- return fn
171
-
172
- return decorator
173
-
174
-
175
67
  class _EncryptDecorators:
176
68
  """Decorators for encryption handlers.
177
69
 
@@ -181,7 +73,6 @@ class _EncryptDecorators:
181
73
 
182
74
  def __init__(self, parent: Encryption):
183
75
  self._parent = parent
184
- self._json = _JsonEncryptDecorators(parent)
185
76
 
186
77
  def blob(self, fn: types.BlobEncryptor) -> types.BlobEncryptor:
187
78
  """Register a blob encryption handler.
@@ -212,29 +103,32 @@ class _EncryptDecorators:
212
103
  self._parent._blob_encryptor = fn
213
104
  return fn
214
105
 
215
- @property
216
- def json(self) -> _JsonEncryptDecorators:
217
- """Access JSON encryption decorators.
218
-
219
- Supports model-specific handlers:
220
- - @encryption.encrypt.json - default handler for all models
221
- - @encryption.encrypt.json.thread - handler for thread model only
222
- - @encryption.encrypt.json.assistant - handler for assistant model only
106
+ def json(self, fn: types.JsonEncryptor) -> types.JsonEncryptor:
107
+ """Register the JSON encryption handler.
223
108
 
224
109
  Example:
225
110
  ```python
226
111
  @encryption.encrypt.json
227
- async def default_encrypt(ctx: EncryptionContext, data: dict) -> dict:
228
- # Default encryption for all models
112
+ async def encrypt_json(ctx: EncryptionContext, data: dict) -> dict:
113
+ # Encrypt the data
229
114
  return encrypt_data(data)
230
-
231
- @encryption.encrypt.json.thread
232
- async def encrypt_thread(ctx: EncryptionContext, data: dict) -> dict:
233
- # Special encryption for thread model only
234
- return encrypt_thread_data(data)
235
115
  ```
116
+
117
+ Args:
118
+ fn: The encryption handler function
119
+
120
+ Returns:
121
+ The registered handler function
122
+
123
+ Raises:
124
+ DuplicateHandlerError: If JSON encryptor already registered
125
+ TypeError: If handler has invalid signature
236
126
  """
237
- return self._json
127
+ if self._parent._json_encryptor is not None:
128
+ raise DuplicateHandlerError("JSON encryptor already registered")
129
+ _validate_handler(fn, "JSON encryptor")
130
+ self._parent._json_encryptor = fn
131
+ return fn
238
132
 
239
133
 
240
134
  class _DecryptDecorators:
@@ -246,7 +140,6 @@ class _DecryptDecorators:
246
140
 
247
141
  def __init__(self, parent: Encryption):
248
142
  self._parent = parent
249
- self._json = _JsonDecryptDecorators(parent)
250
143
 
251
144
  def blob(self, fn: types.BlobDecryptor) -> types.BlobDecryptor:
252
145
  """Register a blob decryption handler.
@@ -277,29 +170,32 @@ class _DecryptDecorators:
277
170
  self._parent._blob_decryptor = fn
278
171
  return fn
279
172
 
280
- @property
281
- def json(self) -> _JsonDecryptDecorators:
282
- """Access JSON decryption decorators.
283
-
284
- Supports model-specific handlers:
285
- - @encryption.decrypt.json - default handler for all models
286
- - @encryption.decrypt.json.thread - handler for thread model only
287
- - @encryption.decrypt.json.assistant - handler for assistant model only
173
+ def json(self, fn: types.JsonDecryptor) -> types.JsonDecryptor:
174
+ """Register the JSON decryption handler.
288
175
 
289
176
  Example:
290
177
  ```python
291
178
  @encryption.decrypt.json
292
- async def default_decrypt(ctx: EncryptionContext, data: dict) -> dict:
293
- # Default decryption for all models
179
+ async def decrypt_json(ctx: EncryptionContext, data: dict) -> dict:
180
+ # Decrypt the data
294
181
  return decrypt_data(data)
295
-
296
- @encryption.decrypt.json.thread
297
- async def decrypt_thread(ctx: EncryptionContext, data: dict) -> dict:
298
- # Special decryption for thread model only
299
- return decrypt_thread_data(data)
300
182
  ```
183
+
184
+ Args:
185
+ fn: The decryption handler function
186
+
187
+ Returns:
188
+ The registered handler function
189
+
190
+ Raises:
191
+ DuplicateHandlerError: If JSON decryptor already registered
192
+ TypeError: If handler has invalid signature
301
193
  """
302
- return self._json
194
+ if self._parent._json_decryptor is not None:
195
+ raise DuplicateHandlerError("JSON decryptor already registered")
196
+ _validate_handler(fn, "JSON decryptor")
197
+ self._parent._json_decryptor = fn
198
+ return fn
303
199
 
304
200
 
305
201
  class Encryption:
@@ -336,6 +232,28 @@ class Encryption:
336
232
  Then the LangGraph server will load your encryption file and use it to
337
233
  encrypt/decrypt data at rest.
338
234
 
235
+ !!! warning "JSON Encryptors Must Preserve Keys"
236
+
237
+ JSON encryptors **must not add or remove keys** from the input dict.
238
+ Only values may be transformed. This constraint is **enforced at runtime
239
+ by the server** and exists because SQL JSONB merge operations (used for
240
+ partial updates) work at the key level.
241
+
242
+ **Correct (per-key encryption):**
243
+ ```python
244
+ # Input: {"secret": "value", "plain": "x"}
245
+ # Output: {"secret": "<encrypted>", "plain": "x"} ✓ Keys preserved
246
+ ```
247
+
248
+ **Incorrect (key consolidation):**
249
+ ```python
250
+ # Input: {"secret": "value", "plain": "x"}
251
+ # Output: {"__encrypted__": "<blob>", "plain": "x"} ✗ Key changed
252
+ ```
253
+
254
+ If your encryptor needs to store auxiliary data (DEK, IV, etc.), embed it
255
+ within the encrypted value itself, not as separate keys.
256
+
339
257
  ???+ example "Basic Usage"
340
258
 
341
259
  ```python
@@ -343,89 +261,48 @@ class Encryption:
343
261
 
344
262
  my_encryption = Encryption()
345
263
 
264
+ SKIP_FIELDS = {"tenant_id", "owner", "thread_id", "assistant_id"}
265
+ ENCRYPTED_PREFIX = "encrypted:"
266
+
346
267
  @my_encryption.encrypt.blob
347
268
  async def encrypt_blob(ctx: EncryptionContext, blob: bytes) -> bytes:
348
- # Call your encryption service
349
- return encrypted_blob
269
+ return your_encrypt_bytes(blob)
350
270
 
351
271
  @my_encryption.decrypt.blob
352
272
  async def decrypt_blob(ctx: EncryptionContext, blob: bytes) -> bytes:
353
- # Call your decryption service
354
- return decrypted_blob
273
+ return your_decrypt_bytes(blob)
355
274
 
356
275
  @my_encryption.encrypt.json
357
276
  async def encrypt_json(ctx: EncryptionContext, data: dict) -> dict:
358
- # Practical encryption strategy:
359
- # - "owner" field: unencrypted (for search/filtering)
360
- # - "my.customer.org/" prefixed fields: encrypt VALUES only
361
- # - All other fields: pass through unencrypted
362
- encrypted = {}
363
- for key, value in data.items():
364
- if key.startswith("my.customer.org/"):
365
- # Encrypt VALUE for sensitive customer data
366
- encrypted[key] = encrypt_value(value)
277
+ result = {}
278
+ for k, v in data.items():
279
+ if k in SKIP_FIELDS or v is None:
280
+ result[k] = v
367
281
  else:
368
- # Pass through (including "owner" for search)
369
- encrypted[key] = value
370
- return encrypted
282
+ result[k] = ENCRYPTED_PREFIX + your_encrypt_string(v)
283
+ return result
371
284
 
372
285
  @my_encryption.decrypt.json
373
286
  async def decrypt_json(ctx: EncryptionContext, data: dict) -> dict:
374
- # Decrypt VALUES for "my.customer.org/" prefixed fields
375
- decrypted = {}
376
- for key, value in data.items():
377
- if key.startswith("my.customer.org/"):
378
- decrypted[key] = decrypt_value(value)
287
+ result = {}
288
+ for k, v in data.items():
289
+ if isinstance(v, str) and v.startswith(ENCRYPTED_PREFIX):
290
+ result[k] = your_decrypt_string(v[len(ENCRYPTED_PREFIX):])
379
291
  else:
380
- decrypted[key] = value
381
- return decrypted
382
- ```
383
-
384
- ???+ example "Model-Specific Handlers"
385
-
386
- You can register different encryption handlers for different model types
387
- (thread, assistant, run, cron, checkpoint, etc.):
388
-
389
- ```python
390
- from langgraph_sdk import Encryption, EncryptionContext
391
-
392
- my_encryption = Encryption()
393
-
394
- # Default handler for models without specific handlers
395
- @my_encryption.encrypt.json
396
- async def default_encrypt(ctx: EncryptionContext, data: dict) -> dict:
397
- return standard_encrypt(data)
398
-
399
- # Thread-specific handler (uses different KMS key)
400
- @my_encryption.encrypt.json.thread
401
- async def encrypt_thread(ctx: EncryptionContext, data: dict) -> dict:
402
- return encrypt_with_thread_key(data)
403
-
404
- # Assistant-specific handler
405
- @my_encryption.encrypt.json.assistant
406
- async def encrypt_assistant(ctx: EncryptionContext, data: dict) -> dict:
407
- return encrypt_with_assistant_key(data)
408
-
409
- # Same pattern for decryption
410
- @my_encryption.decrypt.json
411
- async def default_decrypt(ctx: EncryptionContext, data: dict) -> dict:
412
- return standard_decrypt(data)
413
-
414
- @my_encryption.decrypt.json.thread
415
- async def decrypt_thread(ctx: EncryptionContext, data: dict) -> dict:
416
- return decrypt_with_thread_key(data)
292
+ result[k] = v
293
+ return result
417
294
  ```
418
295
 
419
296
  ???+ example "Field-Specific Logic"
420
297
 
421
- The `ctx.field` attribute tells you which specific field is being encrypted,
422
- allowing different logic within the same model:
298
+ The `ctx.model` and `ctx.field` attributes tell you which model type and
299
+ specific field is being encrypted, allowing different logic:
423
300
 
424
301
  ```python
425
- @my_encryption.encrypt.json.thread
426
- async def encrypt_thread(ctx: EncryptionContext, data: dict) -> dict:
302
+ @my_encryption.encrypt.json
303
+ async def encrypt_json(ctx: EncryptionContext, data: dict) -> dict:
427
304
  if ctx.field == "metadata":
428
- # Thread metadata - standard encryption
305
+ # Metadata - standard encryption
429
306
  return encrypt_standard(data)
430
307
  elif ctx.field == "values":
431
308
  # Thread values - more sensitive, use stronger encryption
@@ -433,6 +310,49 @@ class Encryption:
433
310
  else:
434
311
  return encrypt_standard(data)
435
312
  ```
313
+
314
+ !!! warning "Model/Field May Differ Between Encrypt and Decrypt"
315
+
316
+ Data encrypted with one `(model, field)` pair is **not guaranteed**
317
+ to be decrypted with the same pair. The server performs SQL JSONB
318
+ merges that can move encrypted values between models (e.g., cron
319
+ metadata → run metadata). Your decryption logic must handle data
320
+ regardless of the `ctx.model` or `ctx.field` values at decrypt time.
321
+
322
+ **Safe:** Use `ctx.model`/`ctx.field` for logging or metrics only.
323
+
324
+ **Safe:** Encrypt different keys based on `ctx.field`, but use a
325
+ single decrypt handler that decrypts any value with the encrypted
326
+ prefix (and passes through plaintext unchanged):
327
+
328
+ ```python
329
+ ENCRYPTED_PREFIX = "enc:"
330
+
331
+ @my_encryption.encrypt.json
332
+ async def encrypt_json(ctx: EncryptionContext, data: dict) -> dict:
333
+ # Encrypt different keys depending on the field
334
+ if ctx.field == "context":
335
+ keys_to_encrypt = {"api_key", "secret_token"}
336
+ else:
337
+ keys_to_encrypt = {"email", "ssn"}
338
+ return {
339
+ k: ENCRYPTED_PREFIX + encrypt(v) if k in keys_to_encrypt else v
340
+ for k, v in data.items()
341
+ }
342
+
343
+ @my_encryption.decrypt.json
344
+ async def decrypt_json(ctx: EncryptionContext, data: dict) -> dict:
345
+ # Decrypt ANY value with the prefix, regardless of model/field
346
+ return {
347
+ k: decrypt(v[len(ENCRYPTED_PREFIX):])
348
+ if isinstance(v, str) and v.startswith(ENCRYPTED_PREFIX)
349
+ else v
350
+ for k, v in data.items()
351
+ }
352
+ ```
353
+
354
+ **Unsafe:** Using different encryption keys or algorithms based on
355
+ `ctx.model`/`ctx.field` will cause decryption failures.
436
356
  """
437
357
 
438
358
  __slots__ = (
@@ -440,9 +360,7 @@ class Encryption:
440
360
  "_blob_encryptor",
441
361
  "_context_handler",
442
362
  "_json_decryptor",
443
- "_json_decryptors",
444
363
  "_json_encryptor",
445
- "_json_encryptors",
446
364
  "decrypt",
447
365
  "encrypt",
448
366
  )
@@ -464,8 +382,6 @@ class Encryption:
464
382
  self._blob_decryptor: types.BlobDecryptor | None = None
465
383
  self._json_encryptor: types.JsonEncryptor | None = None
466
384
  self._json_decryptor: types.JsonDecryptor | None = None
467
- self._json_encryptors: dict[str, types.JsonEncryptor] = {}
468
- self._json_decryptors: dict[str, types.JsonDecryptor] = {}
469
385
  self._context_handler: types.ContextHandler | None = None
470
386
 
471
387
  def context(self, fn: types.ContextHandler) -> types.ContextHandler:
@@ -506,33 +422,33 @@ class Encryption:
506
422
  return fn
507
423
 
508
424
  def get_json_encryptor(
509
- self, model: str | None = None
425
+ self,
426
+ _model: str | None = None, # kept for langgraph-api compat
510
427
  ) -> types.JsonEncryptor | None:
511
- """Get the JSON encryptor for a specific model.
428
+ """Get the JSON encryptor.
512
429
 
513
430
  Args:
514
- model: The model type (e.g., "thread", "assistant"). If None, returns default.
431
+ _model: Ignored. Kept for backwards compatibility with langgraph-api
432
+ which passes model_type to this method.
515
433
 
516
434
  Returns:
517
- Model-specific encryptor if registered, otherwise default encryptor, or None.
435
+ The JSON encryptor, or None if not registered.
518
436
  """
519
- if model and model in self._json_encryptors:
520
- return self._json_encryptors[model]
521
437
  return self._json_encryptor
522
438
 
523
439
  def get_json_decryptor(
524
- self, model: str | None = None
440
+ self,
441
+ _model: str | None = None, # kept for langgraph-api compat
525
442
  ) -> types.JsonDecryptor | None:
526
- """Get the JSON decryptor for a specific model.
443
+ """Get the JSON decryptor.
527
444
 
528
445
  Args:
529
- model: The model type (e.g., "thread", "assistant"). If None, returns default.
446
+ _model: Ignored. Kept for backwards compatibility with langgraph-api
447
+ which passes model_type to this method.
530
448
 
531
449
  Returns:
532
- Model-specific decryptor if registered, otherwise default decryptor, or None.
450
+ The JSON decryptor, or None if not registered.
533
451
  """
534
- if model and model in self._json_decryptors:
535
- return self._json_decryptors[model]
536
452
  return self._json_decryptor
537
453
 
538
454
  def __repr__(self) -> str:
@@ -545,10 +461,6 @@ class Encryption:
545
461
  handlers.append("json_encryptor")
546
462
  if self._json_decryptor:
547
463
  handlers.append("json_decryptor")
548
- if self._json_encryptors:
549
- handlers.append(f"json_encryptors({list(self._json_encryptors.keys())})")
550
- if self._json_decryptors:
551
- handlers.append(f"json_decryptors({list(self._json_decryptors.keys())})")
552
464
  if self._context_handler:
553
465
  handlers.append("context_handler")
554
466
  return f"Encryption(handlers=[{', '.join(handlers)}])"
@@ -354,6 +354,8 @@ class Cron(TypedDict):
354
354
  """The ID of the assistant."""
355
355
  thread_id: str | None
356
356
  """The ID of the thread."""
357
+ on_run_completed: OnCompletionBehavior | None
358
+ """What to do with the thread after the run completes. Only applicable for stateless crons."""
357
359
  end_time: datetime | None
358
360
  """The end date to stop running the cron."""
359
361
  schedule: str
@@ -26,14 +26,6 @@ class TestHandlerValidation:
26
26
  async def json_dec(_ctx, data):
27
27
  return data
28
28
 
29
- @encryption.encrypt.json.thread
30
- async def thread_enc(_ctx, data):
31
- return data
32
-
33
- @encryption.decrypt.json.custom
34
- async def custom_dec(_ctx, data):
35
- return data
36
-
37
29
  # All duplicates should raise
38
30
  with pytest.raises(DuplicateHandlerError):
39
31
 
@@ -59,18 +51,6 @@ class TestHandlerValidation:
59
51
  async def dup(_ctx, data):
60
52
  return data
61
53
 
62
- with pytest.raises(DuplicateHandlerError):
63
-
64
- @encryption.encrypt.json.thread
65
- async def dup(_ctx, data):
66
- return data
67
-
68
- with pytest.raises(DuplicateHandlerError):
69
-
70
- @encryption.decrypt.json.custom
71
- async def dup(_ctx, data):
72
- return data
73
-
74
54
  def test_handlers_must_be_async(self):
75
55
  """Sync functions raise TypeError."""
76
56
  encryption = Encryption()
@@ -1,184 +0,0 @@
1
- .vs/
2
- .vscode/
3
- .idea/
4
- # Byte-compiled / optimized / DLL files
5
- __pycache__/
6
- *.py[cod]
7
- *$py.class
8
-
9
- # C extensions
10
- *.so
11
-
12
- # Distribution / packaging
13
- .Python
14
- build/
15
- develop-eggs/
16
- dist/
17
- downloads/
18
- eggs/
19
- .eggs/
20
- lib/
21
- lib64/
22
- parts/
23
- sdist/
24
- var/
25
- wheels/
26
- pip-wheel-metadata/
27
- share/python-wheels/
28
- *.egg-info/
29
- .installed.cfg
30
- *.egg
31
- MANIFEST
32
-
33
- # PyInstaller
34
- # Usually these files are written by a python script from a template
35
- # before PyInstaller builds the exe, so as to inject date/other infos into it.
36
- *.manifest
37
- *.spec
38
-
39
- # Installer logs
40
- pip-log.txt
41
- pip-delete-this-directory.txt
42
-
43
- # Unit test / coverage reports
44
- htmlcov/
45
- .tox/
46
- .nox/
47
- .coverage
48
- .coverage.*
49
- .cache
50
- nosetests.xml
51
- coverage.xml
52
- *.cover
53
- *.py,cover
54
- .hypothesis/
55
- .pytest_cache/
56
-
57
- # Translations
58
- *.mo
59
- *.pot
60
-
61
- # Django stuff:
62
- *.log
63
- local_settings.py
64
- db.sqlite3
65
- db.sqlite3-journal
66
-
67
- # Flask stuff:
68
- instance/
69
- .webassets-cache
70
-
71
- # Scrapy stuff:
72
- .scrapy
73
-
74
- # Sphinx documentation
75
- docs/_build/
76
- docs/docs/_build/
77
-
78
- # PyBuilder
79
- target/
80
-
81
- # Jupyter Notebook
82
- .ipynb_checkpoints
83
- notebooks/
84
-
85
- # IPython
86
- profile_default/
87
- ipython_config.py
88
-
89
- # pyenv
90
- .python-version
91
-
92
- # pipenv
93
- # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
94
- # However, in case of collaboration, if having platform-specific dependencies or dependencies
95
- # having no cross-platform support, pipenv may install dependencies that don't work, or not
96
- # install all needed dependencies.
97
- #Pipfile.lock
98
-
99
- # PEP 582; used by e.g. github.com/David-OConnor/pyflow
100
- __pypackages__/
101
-
102
- # Celery stuff
103
- celerybeat-schedule
104
- celerybeat.pid
105
-
106
- # SageMath parsed files
107
- *.sage.py
108
-
109
- # Environments
110
- .env
111
- .envrc
112
- .venv
113
- .venvs
114
- env/
115
- venv/
116
- ENV/
117
- env.bak/
118
- venv.bak/
119
-
120
- # Spyder project settings
121
- .spyderproject
122
- .spyproject
123
-
124
- # Rope project settings
125
- .ropeproject
126
-
127
- # mkdocs documentation
128
- /site
129
-
130
- # mypy
131
- .mypy_cache/
132
- .dmypy.json
133
- dmypy.json
134
-
135
- # Pyre type checker
136
- .pyre/
137
-
138
- # macOS display setting files
139
- .DS_Store
140
-
141
- # Wandb directory
142
- wandb/
143
-
144
- # asdf tool versions
145
- .tool-versions
146
- /.ruff_cache/
147
-
148
- *.pkl
149
- *.bin
150
-
151
- # integration test artifacts
152
- data_map*
153
- \[('_type', 'fake'), ('stop', None)]
154
-
155
- # Replit files
156
- *replit*
157
-
158
- node_modules
159
- docs/.yarn/
160
- docs/node_modules/
161
- docs/.docusaurus/
162
- docs/.cache-loader/
163
- docs/_dist
164
- docs/api_reference/api_reference.rst
165
- docs/api_reference/experimental_api_reference.rst
166
- docs/api_reference/_build
167
- docs/api_reference/*/
168
- !docs/api_reference/_static/
169
- !docs/api_reference/templates/
170
- !docs/api_reference/themes/
171
- docs/docs_skeleton/build
172
- docs/docs_skeleton/node_modules
173
- docs/docs_skeleton/yarn.lock
174
-
175
- # Any new jupyter notebooks
176
- # not intended for the repo
177
- Untitled*.ipynb
178
-
179
- Chinook.db
180
-
181
- .vercel
182
- .turbo
183
- .editorconfig
184
- .scratch
@@ -1,76 +0,0 @@
1
- import functools
2
- import json
3
- import os
4
- from pathlib import Path
5
- from typing import get_args
6
-
7
- from langgraph_sdk.schema import (
8
- AssistantSelectField,
9
- CronSelectField,
10
- RunSelectField,
11
- ThreadSelectField,
12
- )
13
-
14
- current_dir = os.path.dirname(os.path.abspath(__file__))
15
-
16
-
17
- @functools.cache
18
- def _load_spec() -> dict:
19
- with (
20
- Path(current_dir).parents[2]
21
- / "docs"
22
- / "docs"
23
- / "cloud"
24
- / "reference"
25
- / "api"
26
- / "openapi.json"
27
- ).open() as f:
28
- return json.load(f)
29
-
30
-
31
- def _enum_from_request_select(spec: dict, path: str, method: str) -> set[str]:
32
- schema = spec["paths"][path][method]["requestBody"]["content"]["application/json"][
33
- "schema"
34
- ]
35
- if "properties" in schema:
36
- props = schema["properties"]
37
- elif "$ref" in schema:
38
- component = spec
39
- index = schema["$ref"].split("/")[1:]
40
- for part in index:
41
- component = component[part]
42
- props = component["properties"]
43
- else:
44
- raise ValueError(f"Unknown schema: {schema}")
45
- sel = props["select"]
46
- return set(sel["items"]["enum"])
47
-
48
-
49
- def _enum_from_query_select(spec: dict, path: str, method: str) -> set[str]:
50
- params = spec["paths"][path][method]["parameters"]
51
- sel = next(p for p in params if p["name"] == "select")
52
- return set(sel["schema"]["items"]["enum"])
53
-
54
-
55
- def test_assistants_select_enum_matches_sdk():
56
- spec = _load_spec()
57
- expected = set(get_args(AssistantSelectField))
58
- assert _enum_from_request_select(spec, "/assistants/search", "post") == expected
59
-
60
-
61
- def test_threads_select_enum_matches_sdk():
62
- spec = _load_spec()
63
- expected = set(get_args(ThreadSelectField))
64
- assert _enum_from_request_select(spec, "/threads/search", "post") == expected
65
-
66
-
67
- def test_runs_select_enum_matches_sdk():
68
- spec = _load_spec()
69
- expected = set(get_args(RunSelectField))
70
- assert _enum_from_query_select(spec, "/threads/{thread_id}/runs", "get") == expected
71
-
72
-
73
- def test_crons_select_enum_matches_sdk():
74
- spec = _load_spec()
75
- expected = set(get_args(CronSelectField))
76
- assert _enum_from_request_select(spec, "/runs/crons/search", "post") == expected
File without changes
File without changes
File without changes
File without changes