langgraph-api 0.0.33__tar.gz → 0.0.36__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.

Potentially problematic release.


This version of langgraph-api might be problematic. Click here for more details.

Files changed (101) hide show
  1. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/PKG-INFO +2 -2
  2. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/api/runs.py +42 -0
  3. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/api/threads.py +1 -0
  4. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/auth/custom.py +6 -13
  5. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/cli.py +11 -3
  6. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/config.py +23 -0
  7. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/lifespan.py +8 -2
  8. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/serde.py +9 -3
  9. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/server.py +10 -1
  10. langgraph_api-0.0.36/langgraph_api/thread_ttl.py +46 -0
  11. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/validation.py +1 -0
  12. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_storage/checkpoint.py +9 -1
  13. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_storage/ops.py +138 -63
  14. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_storage/store.py +3 -0
  15. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/openapi.json +115 -0
  16. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/pyproject.toml +2 -2
  17. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/LICENSE +0 -0
  18. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/README.md +0 -0
  19. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/__init__.py +0 -0
  20. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/api/__init__.py +0 -0
  21. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/api/assistants.py +0 -0
  22. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/api/mcp.py +0 -0
  23. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/api/meta.py +0 -0
  24. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/api/openapi.py +0 -0
  25. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/api/store.py +0 -0
  26. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/api/ui.py +0 -0
  27. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/asyncio.py +0 -0
  28. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/auth/__init__.py +0 -0
  29. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/auth/langsmith/__init__.py +0 -0
  30. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/auth/langsmith/backend.py +0 -0
  31. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/auth/langsmith/client.py +0 -0
  32. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/auth/middleware.py +0 -0
  33. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/auth/noop.py +0 -0
  34. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/auth/studio_user.py +0 -0
  35. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/command.py +0 -0
  36. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/cron_scheduler.py +0 -0
  37. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/errors.py +0 -0
  38. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/graph.py +0 -0
  39. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/http.py +0 -0
  40. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/.gitignore +0 -0
  41. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/base.py +0 -0
  42. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/build.mts +0 -0
  43. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/client.mts +0 -0
  44. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/errors.py +0 -0
  45. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/global.d.ts +0 -0
  46. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/package.json +0 -0
  47. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/remote.py +0 -0
  48. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/schema.py +0 -0
  49. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/src/graph.mts +0 -0
  50. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/src/hooks.mjs +0 -0
  51. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/src/parser/parser.mts +0 -0
  52. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/src/parser/parser.worker.mjs +0 -0
  53. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/src/schema/types.mts +0 -0
  54. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/src/schema/types.template.mts +0 -0
  55. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/src/utils/importMap.mts +0 -0
  56. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/src/utils/pythonSchemas.mts +0 -0
  57. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/src/utils/serde.mts +0 -0
  58. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/sse.py +0 -0
  59. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/tests/api.test.mts +0 -0
  60. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/tests/compose-postgres.yml +0 -0
  61. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/tests/graphs/.gitignore +0 -0
  62. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/tests/graphs/agent.css +0 -0
  63. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/tests/graphs/agent.mts +0 -0
  64. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/tests/graphs/agent.ui.tsx +0 -0
  65. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/tests/graphs/delay.mts +0 -0
  66. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/tests/graphs/error.mts +0 -0
  67. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/tests/graphs/langgraph.json +0 -0
  68. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/tests/graphs/nested.mts +0 -0
  69. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/tests/graphs/package.json +0 -0
  70. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/tests/graphs/weather.mts +0 -0
  71. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/tests/graphs/yarn.lock +0 -0
  72. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/tests/parser.test.mts +0 -0
  73. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/tests/utils.mts +0 -0
  74. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/js/yarn.lock +0 -0
  75. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/logging.py +0 -0
  76. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/metadata.py +0 -0
  77. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/middleware/__init__.py +0 -0
  78. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/middleware/http_logger.py +0 -0
  79. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/middleware/private_network.py +0 -0
  80. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/models/__init__.py +0 -0
  81. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/models/run.py +0 -0
  82. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/patch.py +0 -0
  83. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/queue_entrypoint.py +0 -0
  84. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/route.py +0 -0
  85. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/schema.py +0 -0
  86. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/sse.py +0 -0
  87. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/state.py +0 -0
  88. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/stream.py +0 -0
  89. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/utils.py +0 -0
  90. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/webhook.py +0 -0
  91. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_api/worker.py +0 -0
  92. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_license/__init__.py +0 -0
  93. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_license/middleware.py +0 -0
  94. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_license/validation.py +0 -0
  95. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_storage/__init__.py +0 -0
  96. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_storage/database.py +0 -0
  97. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_storage/inmem_stream.py +0 -0
  98. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_storage/queue.py +0 -0
  99. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_storage/retry.py +0 -0
  100. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/langgraph_storage/ttl_dict.py +0 -0
  101. {langgraph_api-0.0.33 → langgraph_api-0.0.36}/logging.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: langgraph-api
3
- Version: 0.0.33
3
+ Version: 0.0.36
4
4
  Summary:
5
5
  License: Elastic-2.0
6
6
  Author: Nuno Campos
@@ -15,7 +15,7 @@ Requires-Dist: httpx (>=0.25.0)
15
15
  Requires-Dist: jsonschema-rs (>=0.20.0,<0.30)
16
16
  Requires-Dist: langchain-core (>=0.2.38,<0.4.0)
17
17
  Requires-Dist: langgraph (>=0.2.56,<0.4.0)
18
- Requires-Dist: langgraph-checkpoint (>=2.0.21,<3.0)
18
+ Requires-Dist: langgraph-checkpoint (>=2.0.23,<3.0)
19
19
  Requires-Dist: langgraph-sdk (>=0.1.58,<0.2.0)
20
20
  Requires-Dist: langsmith (>=0.1.63,<0.4.0)
21
21
  Requires-Dist: orjson (>=3.9.7)
@@ -1,8 +1,10 @@
1
1
  import asyncio
2
2
  from collections.abc import AsyncIterator
3
+ from typing import Literal
3
4
 
4
5
  import orjson
5
6
  from langgraph.checkpoint.base.id import uuid6
7
+ from starlette.exceptions import HTTPException
6
8
  from starlette.responses import Response, StreamingResponse
7
9
 
8
10
  from langgraph_api import config
@@ -17,6 +19,7 @@ from langgraph_api.validation import (
17
19
  RunBatchCreate,
18
20
  RunCreateStateful,
19
21
  RunCreateStateless,
22
+ RunsCancel,
20
23
  )
21
24
  from langgraph_license.validation import plus_features_enabled
22
25
  from langgraph_storage.database import connect
@@ -394,6 +397,44 @@ async def cancel_run(
394
397
  return Response(status_code=204 if wait else 202)
395
398
 
396
399
 
400
+ @retry_db
401
+ async def cancel_runs(
402
+ request: ApiRequest,
403
+ ):
404
+ """Cancel a run."""
405
+ body = await request.json(RunsCancel)
406
+ status = body.get("status")
407
+ if status:
408
+ status = status.lower()
409
+ if status not in ("pending", "running", "all"):
410
+ raise HTTPException(
411
+ status_code=422,
412
+ detail="Invalid status: must be 'pending', 'running', or 'all'",
413
+ )
414
+ thread_id = None
415
+ run_ids = None
416
+ else:
417
+ thread_id = body.get("thread_id")
418
+ run_ids = body.get("run_ids")
419
+ validate_uuid(thread_id, "Invalid thread ID: must be a UUID")
420
+ for rid in run_ids:
421
+ validate_uuid(rid, "Invalid run ID: must be a UUID")
422
+ action_str = request.query_params.get("action", "interrupt")
423
+ action: Literal["interrupt", "rollback"] = (
424
+ action_str if action_str in ("interrupt", "rollback") else "interrupt"
425
+ )
426
+
427
+ async with connect() as conn:
428
+ await Runs.cancel(
429
+ conn,
430
+ run_ids,
431
+ action=action,
432
+ thread_id=thread_id,
433
+ status=status,
434
+ )
435
+ return Response(status_code=204)
436
+
437
+
397
438
  @retry_db
398
439
  async def delete_run(request: ApiRequest):
399
440
  """Delete a run by ID."""
@@ -486,6 +527,7 @@ runs_routes = [
486
527
  ApiRoute("/runs/wait", wait_run_stateless, methods=["POST"]),
487
528
  ApiRoute("/runs", create_stateless_run, methods=["POST"]),
488
529
  ApiRoute("/runs/batch", create_stateless_run_batch, methods=["POST"]),
530
+ ApiRoute("/runs/cancel", cancel_runs, methods=["POST"]),
489
531
  (
490
532
  ApiRoute("/runs/crons", create_cron, methods=["POST"])
491
533
  if config.FF_CRONS_ENABLED and plus_features_enabled()
@@ -34,6 +34,7 @@ async def create_thread(
34
34
  thread_id,
35
35
  metadata=payload.get("metadata"),
36
36
  if_exists=payload.get("if_exists") or "raise",
37
+ ttl=payload.get("ttl"),
37
38
  )
38
39
 
39
40
  if supersteps := payload.get("supersteps"):
@@ -176,21 +176,14 @@ class CustomAuthBackend(AuthenticationBackend):
176
176
  except (AuthenticationError, HTTPException):
177
177
  raise
178
178
  except Auth.exceptions.HTTPException as e:
179
- raise HTTPException(
180
- status_code=e.status_code,
181
- detail=e.detail,
182
- headers=dict(e.headers) if e.headers else None,
183
- ) from None
184
- except AssertionError as e:
185
- raise AuthenticationError(str(e)) from None
179
+ if e.status_code == 401 or e.status_code == 403:
180
+ raise AuthenticationError(e.detail) from None
181
+ else:
182
+ await logger.aerror("Error authenticating request", exc_info=e)
183
+ raise
186
184
  except Exception as e:
187
185
  await logger.aerror("Error authenticating request", exc_info=e)
188
- status_code = getattr(e, "status_code", 401)
189
- detail = getattr(e, "detail", "Unauthorized")
190
- headers = getattr(e, "headers", None)
191
- raise HTTPException(
192
- status_code=status_code, detail=detail, headers=headers
193
- ) from None
186
+ raise e
194
187
 
195
188
 
196
189
  def _get_custom_auth_middleware(
@@ -178,8 +178,7 @@ def run_server(
178
178
  logger.info("Debugger attached. Starting server...")
179
179
 
180
180
  local_url = f"http://{host}:{port}"
181
-
182
- with patch_environment(
181
+ to_patch = dict(
183
182
  MIGRATIONS_PATH="__inmem",
184
183
  DATABASE_URI=":memory:",
185
184
  REDIS_URI="fake",
@@ -192,7 +191,16 @@ def run_server(
192
191
  LANGGRAPH_API_URL=local_url,
193
192
  # See https://developer.chrome.com/blog/private-network-access-update-2024-03
194
193
  ALLOW_PRIVATE_NETWORK="true",
195
- **(env_vars or {}),
194
+ )
195
+ if env_vars is not None:
196
+ # Don't overwrite.
197
+ for k, v in env_vars.items():
198
+ if k in to_patch:
199
+ logger.debug(f"Skipping loaded env var {k}={v}")
200
+ continue
201
+ to_patch[k] = v
202
+ with patch_environment(
203
+ **to_patch,
196
204
  ):
197
205
  studio_origin = studio_url or _get_ls_origin() or "https://smith.langchain.com"
198
206
  full_studio_url = f"{studio_origin}/studio/?baseUrl={local_url}"
@@ -39,6 +39,12 @@ class HttpConfig(TypedDict, total=False):
39
39
  """Disable /mcp routes"""
40
40
 
41
41
 
42
+ class ThreadTTLConfig(TypedDict, total=False):
43
+ strategy: Literal["delete"]
44
+ default_ttl: float | None
45
+ sweep_interval_minutes: int | None
46
+
47
+
42
48
  class IndexConfig(TypedDict, total=False):
43
49
  """Configuration for indexing documents for semantic search in the store."""
44
50
 
@@ -204,6 +210,23 @@ BG_JOB_MAX_RETRIES = 3
204
210
  BG_JOB_ISOLATED_LOOPS = env("BG_JOB_ISOLATED_LOOPS", cast=bool, default=False)
205
211
 
206
212
 
213
+ def _parse_thread_ttl(value: str | None) -> ThreadTTLConfig | None:
214
+ if not value:
215
+ return None
216
+ if str(value).strip().startswith("{"):
217
+ return _parse_json(value.strip())
218
+ return {
219
+ "strategy": "delete",
220
+ # We permit float values mainly for testing purposes
221
+ "default_ttl": float(value),
222
+ "sweep_interval_minutes": 5.1,
223
+ }
224
+
225
+
226
+ THREAD_TTL: ThreadTTLConfig | None = env(
227
+ "LANGGRAPH_THREAD_TTL", cast=_parse_thread_ttl, default=None
228
+ )
229
+
207
230
  N_JOBS_PER_WORKER = env("N_JOBS_PER_WORKER", cast=int, default=10)
208
231
  BG_JOB_TIMEOUT_SECS = env("BG_JOB_TIMEOUT_SECS", cast=float, default=3600)
209
232
  FF_CRONS_ENABLED = env("FF_CRONS_ENABLED", cast=bool, default=True)
@@ -2,6 +2,8 @@ import asyncio
2
2
  from contextlib import asynccontextmanager
3
3
 
4
4
  import structlog
5
+ from langchain_core.runnables.config import var_child_runnable_config
6
+ from langgraph.constants import CONF, CONFIG_KEY_STORE
5
7
  from starlette.applications import Starlette
6
8
 
7
9
  import langgraph_api.config as config
@@ -10,6 +12,7 @@ from langgraph_api.cron_scheduler import cron_scheduler
10
12
  from langgraph_api.graph import collect_graphs_from_env, stop_remote_graphs
11
13
  from langgraph_api.http import start_http_client, stop_http_client
12
14
  from langgraph_api.metadata import metadata_loop
15
+ from langgraph_api.thread_ttl import thread_ttl_sweep_loop
13
16
  from langgraph_license.validation import get_license_status, plus_features_enabled
14
17
  from langgraph_storage.database import start_pool, stop_pool
15
18
  from langgraph_storage.queue import queue
@@ -54,8 +57,11 @@ async def lifespan(
54
57
  and plus_features_enabled()
55
58
  ):
56
59
  tg.create_task(cron_scheduler())
57
- if config.STORE_CONFIG and config.STORE_CONFIG.get("ttl"):
58
- tg.create_task(Store().start_ttl_sweeper())
60
+ store = Store()
61
+ tg.create_task(Store().start_ttl_sweeper())
62
+ tg.create_task(thread_ttl_sweep_loop())
63
+ var_child_runnable_config.set({CONF: {CONFIG_KEY_STORE: store}})
64
+
59
65
  yield
60
66
  finally:
61
67
  await stop_remote_graphs()
@@ -52,11 +52,17 @@ def default(obj):
52
52
  # https://github.com/ijl/orjson#serialize
53
53
  if isinstance(obj, Fragment):
54
54
  return orjson.Fragment(obj.buf)
55
- if hasattr(obj, "model_dump") and callable(obj.model_dump):
55
+ if (
56
+ hasattr(obj, "model_dump")
57
+ and callable(obj.model_dump)
58
+ and not isinstance(obj, type)
59
+ ):
56
60
  return obj.model_dump()
57
- elif hasattr(obj, "dict") and callable(obj.dict):
61
+ elif hasattr(obj, "dict") and callable(obj.dict) and not isinstance(obj, type):
58
62
  return obj.dict()
59
- elif hasattr(obj, "_asdict") and callable(obj._asdict):
63
+ elif (
64
+ hasattr(obj, "_asdict") and callable(obj._asdict) and not isinstance(obj, type)
65
+ ):
60
66
  return obj._asdict()
61
67
  elif isinstance(obj, BaseException):
62
68
  return {"error": type(obj).__name__, "message": str(obj)}
@@ -16,7 +16,7 @@ from starlette.middleware.cors import CORSMiddleware
16
16
  from langgraph_api.api.openapi import set_custom_spec
17
17
 
18
18
  import langgraph_api.config as config
19
- from langgraph_api.api import routes, user_router
19
+ from langgraph_api.api import routes, meta_routes, user_router
20
20
  from langgraph_api.errors import (
21
21
  overloaded_error_handler,
22
22
  validation_error_handler,
@@ -92,6 +92,15 @@ def update_openapi_spec(app):
92
92
  if user_router:
93
93
  # Merge routes
94
94
  app = user_router
95
+
96
+ meta_route_paths = [route.path for route in meta_routes]
97
+ custom_route_paths = [
98
+ route.path
99
+ for route in user_router.router.routes
100
+ if route.path not in meta_route_paths
101
+ ]
102
+ logger.info(f"Custom route paths: {custom_route_paths}")
103
+
95
104
  update_openapi_spec(app)
96
105
  for route in routes:
97
106
  if route.path in ("/docs", "/openapi.json"):
@@ -0,0 +1,46 @@
1
+ """Sweeping logic for cleaning up expired threads and checkpoints."""
2
+
3
+ import asyncio
4
+
5
+ import structlog
6
+
7
+ from langgraph_api.config import THREAD_TTL
8
+ from langgraph_storage.database import connect
9
+
10
+ logger = structlog.stdlib.get_logger(__name__)
11
+
12
+
13
+ async def thread_ttl_sweep_loop():
14
+ """Periodically delete threads based on TTL configuration.
15
+
16
+ Currently implements the 'delete' strategy, which deletes entire threads
17
+ that have been inactive for longer than their configured TTL period.
18
+ """
19
+ # Use the same interval as store TTL sweep
20
+ thread_ttl_config = THREAD_TTL or {}
21
+ strategy = thread_ttl_config.get("strategy", "delete")
22
+ if strategy != "delete":
23
+ raise NotImplementedError(
24
+ f"Unrecognized thread deletion strategy: {strategy}." " Expected 'delete'."
25
+ )
26
+ sweep_interval_minutes = thread_ttl_config.get("sweep_interval_minutes", 5)
27
+ await logger.ainfo(
28
+ "Starting thread TTL sweeper",
29
+ interval_minutes=sweep_interval_minutes,
30
+ )
31
+
32
+ from langgraph_storage.ops import Threads
33
+
34
+ while True:
35
+ await asyncio.sleep(sweep_interval_minutes * 60)
36
+ try:
37
+ async with connect() as conn:
38
+ threads_processed, threads_deleted = await Threads.sweep_ttl(conn)
39
+ if threads_processed > 0:
40
+ await logger.ainfo(
41
+ "Thread TTL sweep completed",
42
+ threads_processed=threads_processed,
43
+ threads_deleted=threads_deleted,
44
+ )
45
+ except Exception as exc:
46
+ logger.exception("Thread TTL sweep iteration failed", exc_info=exc)
@@ -113,6 +113,7 @@ RunCreateStateful = jsonschema_rs.validator_for(
113
113
  },
114
114
  }
115
115
  )
116
+ RunsCancel = jsonschema_rs.validator_for(openapi["components"]["schemas"]["RunsCancel"])
116
117
  CronCreate = jsonschema_rs.validator_for(openapi["components"]["schemas"]["CronCreate"])
117
118
  CronSearch = jsonschema_rs.validator_for(openapi["components"]["schemas"]["CronSearch"])
118
119
 
@@ -105,7 +105,15 @@ class InMemorySaver(MemorySaver):
105
105
  MEMORY = InMemorySaver()
106
106
 
107
107
 
108
- def Checkpointer(*args, **kwargs):
108
+ def Checkpointer(*args, unpack_hook=None, **kwargs):
109
+ if unpack_hook is not None:
110
+ saver = InMemorySaver(
111
+ serde=Serializer(__unpack_ext_hook__=unpack_hook), **kwargs
112
+ )
113
+ saver.writes = MEMORY.writes
114
+ saver.blobs = MEMORY.blobs
115
+ saver.storage = MEMORY.storage
116
+ return saver
109
117
  return MEMORY
110
118
 
111
119
 
@@ -15,6 +15,7 @@ from typing import Any, Literal, cast
15
15
  from uuid import UUID, uuid4
16
16
 
17
17
  import structlog
18
+ from langgraph.checkpoint.serde.jsonplus import _msgpack_ext_hook_to_json
18
19
  from langgraph.pregel.debug import CheckpointPayload
19
20
  from langgraph.pregel.types import StateSnapshot
20
21
  from langgraph_sdk import Auth
@@ -23,6 +24,7 @@ from starlette.exceptions import HTTPException
23
24
  from langgraph_api.asyncio import SimpleTaskGroup, ValueEvent, create_task
24
25
  from langgraph_api.auth.custom import handle_event
25
26
  from langgraph_api.command import map_cmd
27
+ from langgraph_api.config import ThreadTTLConfig
26
28
  from langgraph_api.errors import UserInterrupt, UserRollback
27
29
  from langgraph_api.graph import get_graph
28
30
  from langgraph_api.schema import (
@@ -671,6 +673,7 @@ class Threads(Authenticated):
671
673
  *,
672
674
  metadata: MetadataInput,
673
675
  if_exists: OnConflictBehavior,
676
+ ttl: ThreadTTLConfig | None = None,
674
677
  ctx: Auth.types.BaseAuthContext | None = None,
675
678
  ) -> AsyncIterator[Thread]:
676
679
  """Insert or update a thread."""
@@ -949,12 +952,27 @@ class Threads(Authenticated):
949
952
  checkpointer.writes[
950
953
  (str(new_thread_id), checkpoint_ns, checkpoint_id)
951
954
  ] = mapped
955
+ # Copy the blobs
956
+ for k in list(checkpointer.blobs):
957
+ if str(k[0]) == str(thread_id):
958
+ new_key = (str(new_thread_id), *k[1:])
959
+ checkpointer.blobs[new_key] = checkpointer.blobs[k]
952
960
 
953
961
  async def row_generator() -> AsyncIterator[Thread]:
954
962
  yield new_thread
955
963
 
956
964
  return row_generator()
957
965
 
966
+ @staticmethod
967
+ async def sweep_ttl(
968
+ conn: InMemConnectionProto,
969
+ *,
970
+ limit: int | None = None,
971
+ batch_size: int = 100,
972
+ ) -> tuple[int, int]:
973
+ # Not implemented for inmem server
974
+ return (0, 0)
975
+
958
976
  class State(Authenticated):
959
977
  # We will treat this like a runs resource for now.
960
978
  resource = "threads"
@@ -967,7 +985,7 @@ class Threads(Authenticated):
967
985
  ctx: Auth.types.BaseAuthContext | None = None,
968
986
  ) -> StateSnapshot:
969
987
  """Get state for a thread."""
970
- checkpointer = Checkpointer(conn)
988
+ checkpointer = Checkpointer(conn, unpack_hook=_msgpack_ext_hook_to_json)
971
989
  thread_id = _ensure_uuid(config["configurable"]["thread_id"])
972
990
  # Auth will be applied here so no need to use filters downstream
973
991
  thread_iter = await Threads.get(conn, thread_id, ctx=ctx)
@@ -1186,7 +1204,11 @@ class Threads(Authenticated):
1186
1204
  # If graph_id exists, get state history
1187
1205
  if graph_id := thread_metadata.get("graph_id"):
1188
1206
  async with get_graph(
1189
- graph_id, thread_config, checkpointer=Checkpointer(conn)
1207
+ graph_id,
1208
+ thread_config,
1209
+ checkpointer=Checkpointer(
1210
+ conn, unpack_hook=_msgpack_ext_hook_to_json
1211
+ ),
1190
1212
  ) as graph:
1191
1213
  # Convert before parameter if it's a string
1192
1214
  before_param = (
@@ -1443,6 +1465,7 @@ class Runs(Authenticated):
1443
1465
  ),
1444
1466
  created_at=datetime.now(UTC),
1445
1467
  updated_at=datetime.now(UTC),
1468
+ values=b"",
1446
1469
  )
1447
1470
  await logger.ainfo("Creating thread", thread_id=thread_id)
1448
1471
  conn.store["threads"].append(thread)
@@ -1659,88 +1682,140 @@ class Runs(Authenticated):
1659
1682
  @staticmethod
1660
1683
  async def cancel(
1661
1684
  conn: InMemConnectionProto,
1662
- run_ids: Sequence[UUID],
1685
+ run_ids: Sequence[UUID] | None = None,
1663
1686
  *,
1664
1687
  action: Literal["interrupt", "rollback"] = "interrupt",
1665
- thread_id: UUID,
1688
+ thread_id: UUID | None = None,
1689
+ status: Literal["pending", "running", "all"] | None = None,
1666
1690
  ctx: Auth.types.BaseAuthContext | None = None,
1667
1691
  ) -> None:
1668
- """Cancel a run."""
1669
- # Authwise, this invokes the runs.update handler
1670
- # Cancellation tries to take two actions, to cover runs in different states:
1671
- # - For any run, send a cancellation message through the stream manager
1672
- # - For queued runs not yet picked up by a worker, update their status if interrupt,
1673
- # delete if rollback.
1674
- # - For runs currently being worked on, the worker will handle cancellation
1675
- # - For runs in any other state, we raise a 404
1676
- run_ids = [_ensure_uuid(run_id) for run_id in run_ids]
1677
- thread_id = _ensure_uuid(thread_id)
1692
+ """
1693
+ Cancel runs in memory. Must provide either:
1694
+ 1) thread_id + run_ids, or
1695
+ 2) status in {"pending", "running", "all"}.
1696
+
1697
+ Steps:
1698
+ - Validate arguments (one usage pattern or the other).
1699
+ - Auth check: 'update' event via handle_event().
1700
+ - Gather runs matching either the (thread_id, run_ids) set or the given status.
1701
+ - For each run found:
1702
+ * Send a cancellation message through the stream manager.
1703
+ * If 'pending', set to 'interrupted' or delete (if action='rollback' and not actively queued).
1704
+ * If 'running', the worker will pick up the message.
1705
+ * Otherwise, log a warning for non-cancelable states.
1706
+ - 404 if no runs are found or authorized.
1707
+ """
1708
+ # 1. Validate arguments
1709
+ if status is not None:
1710
+ # If status is set, user must NOT specify thread_id or run_ids
1711
+ if thread_id is not None or run_ids is not None:
1712
+ raise HTTPException(
1713
+ status_code=422,
1714
+ detail="Cannot specify 'thread_id' or 'run_ids' when using 'status'",
1715
+ )
1716
+ else:
1717
+ # If status is not set, user must specify both thread_id and run_ids
1718
+ if thread_id is None or run_ids is None:
1719
+ raise HTTPException(
1720
+ status_code=422,
1721
+ detail="Must provide either a status or both 'thread_id' and 'run_ids'",
1722
+ )
1723
+
1724
+ # Convert and normalize inputs
1725
+ if run_ids is not None:
1726
+ run_ids = [_ensure_uuid(rid) for rid in run_ids]
1727
+ if thread_id is not None:
1728
+ thread_id = _ensure_uuid(thread_id)
1729
+
1678
1730
  filters = await Runs.handle_event(
1679
1731
  ctx,
1680
1732
  "update",
1681
1733
  Auth.types.ThreadsUpdate(
1682
- thread_id=thread_id,
1734
+ thread_id=thread_id, # type: ignore
1683
1735
  action=action,
1684
- metadata={"run_ids": run_ids},
1736
+ metadata={
1737
+ "run_ids": run_ids,
1738
+ "status": status,
1739
+ },
1685
1740
  ),
1686
1741
  )
1687
1742
 
1743
+ status_list: tuple[str, ...] = ()
1744
+ if status is not None:
1745
+ if status == "all":
1746
+ status_list = ("pending", "running")
1747
+ elif status in ("pending", "running"):
1748
+ status_list = (status,)
1749
+ else:
1750
+ raise ValueError(f"Unsupported status: {status}")
1751
+
1752
+ def is_run_match(r: dict) -> bool:
1753
+ """
1754
+ Check whether a run in `conn.store["runs"]` meets the selection criteria.
1755
+ """
1756
+ if status_list:
1757
+ return r["status"] in status_list
1758
+ else:
1759
+ return r["thread_id"] == thread_id and r["run_id"] in run_ids # type: ignore
1760
+
1761
+ candidate_runs = [r for r in conn.store["runs"] if is_run_match(r)]
1762
+
1763
+ if filters:
1764
+ # If a run is found but not authorized by the thread filters, skip it
1765
+ thread = (
1766
+ await Threads._get_with_filters(conn, thread_id, filters)
1767
+ if thread_id
1768
+ else None
1769
+ )
1770
+ # If there's no matching thread, no runs are authorized.
1771
+ if thread_id and not thread:
1772
+ candidate_runs = []
1773
+ # Otherwise, we might trust that `_get_with_filters` is the only constraint
1774
+ # on thread. If your filters also apply to runs, you might do more checks here.
1775
+
1776
+ if not candidate_runs:
1777
+ raise HTTPException(status_code=404, detail="No runs found to cancel.")
1778
+
1688
1779
  stream_manager = get_stream_manager()
1689
- found_runs = []
1690
1780
  coros = []
1691
- for run_id in run_ids:
1692
- run = next(
1693
- (
1694
- r
1695
- for r in conn.store["runs"]
1696
- if r["run_id"] == run_id and r["thread_id"] == thread_id
1697
- ),
1698
- None,
1781
+ for run in candidate_runs:
1782
+ run_id = run["run_id"]
1783
+ control_message = Message(
1784
+ topic=f"run:{run_id}:control".encode(),
1785
+ data=action.encode(),
1699
1786
  )
1700
- if run:
1701
- if filters:
1702
- thread = await Threads._get_with_filters(conn, thread_id, filters)
1703
- if not thread:
1704
- continue
1705
- found_runs.append(run)
1706
- # Send cancellation message through stream manager
1707
- control_message = Message(
1708
- topic=f"run:{run_id}:control".encode(),
1709
- data=action.encode(),
1710
- )
1711
- queues = stream_manager.get_queues(run_id)
1712
- coros.append(stream_manager.put(run_id, control_message))
1713
-
1714
- # Update status for pending runs
1715
- if run["status"] in ("pending", "running"):
1716
- if queues or action != "rollback":
1717
- run["status"] = "interrupted"
1718
- run["updated_at"] = datetime.now(tz=UTC)
1719
- else:
1720
- await logger.ainfo(
1721
- "Eagerly deleting unscheduled run with rollback action",
1722
- run_id=run_id,
1723
- thread_id=thread_id,
1724
- )
1725
- coros.append(Runs.delete(conn, run_id, thread_id=thread_id))
1787
+ coros.append(stream_manager.put(run_id, control_message))
1788
+
1789
+ queues = stream_manager.get_queues(run_id)
1726
1790
 
1791
+ if run["status"] in ("pending", "running"):
1792
+ if queues or action != "rollback":
1793
+ run["status"] = "interrupted"
1794
+ run["updated_at"] = datetime.now(tz=UTC)
1727
1795
  else:
1728
- await logger.awarning(
1729
- "Attempted to cancel non-pending run.",
1730
- run_id=run_id,
1796
+ await logger.ainfo(
1797
+ "Eagerly deleting pending run with rollback action",
1798
+ run_id=str(run_id),
1731
1799
  status=run["status"],
1732
1800
  )
1801
+ coros.append(Runs.delete(conn, run_id, thread_id=run["thread_id"]))
1802
+ else:
1803
+ await logger.awarning(
1804
+ "Attempted to cancel non-pending run.",
1805
+ run_id=str(run_id),
1806
+ status=run["status"],
1807
+ )
1808
+
1733
1809
  if coros:
1734
1810
  await asyncio.gather(*coros)
1735
- if len(found_runs) == len(run_ids):
1736
- await logger.ainfo(
1737
- "Cancelled runs",
1738
- run_ids=[str(run_id) for run_id in run_ids],
1739
- thread_id=str(thread_id),
1740
- action=action,
1741
- )
1742
- else:
1743
- raise HTTPException(status_code=404, detail="Run not found")
1811
+
1812
+ await logger.ainfo(
1813
+ "Cancelled runs",
1814
+ run_ids=[str(r["run_id"]) for r in candidate_runs],
1815
+ thread_id=str(thread_id) if thread_id else None,
1816
+ status=status,
1817
+ action=action,
1818
+ )
1744
1819
 
1745
1820
  @staticmethod
1746
1821
  async def search(
@@ -62,6 +62,9 @@ class BatchedStore(AsyncBatchedBaseStore):
62
62
  async def abatch(self, ops: Iterable[Op]) -> list[Result]:
63
63
  return await self._store.abatch(ops)
64
64
 
65
+ async def start_ttl_sweeper(self) -> asyncio.Task[None]:
66
+ return await self._store.start_ttl_sweeper()
67
+
65
68
  def close(self) -> None:
66
69
  self._store.close()
67
70
 
@@ -2268,6 +2268,68 @@
2268
2268
  }
2269
2269
  }
2270
2270
  },
2271
+ "/runs/cancel": {
2272
+ "post": {
2273
+ "tags": [
2274
+ "Thread Runs"
2275
+ ],
2276
+ "summary": "Cancel Runs",
2277
+ "description": "Cancel one or more runs. Can cancel runs by thread ID and run IDs, or by status filter.",
2278
+ "operationId": "cancel_runs_post",
2279
+ "parameters": [
2280
+ {
2281
+ "description": "Action to take when cancelling the run. Possible values are `interrupt` or `rollback`. `interrupt` will simply cancel the run. `rollback` will cancel the run and delete the run and associated checkpoints afterwards.",
2282
+ "required": false,
2283
+ "schema": {
2284
+ "type": "string",
2285
+ "enum": [
2286
+ "interrupt",
2287
+ "rollback"
2288
+ ],
2289
+ "title": "Action",
2290
+ "default": "interrupt"
2291
+ },
2292
+ "name": "action",
2293
+ "in": "query"
2294
+ }
2295
+ ],
2296
+ "requestBody": {
2297
+ "content": {
2298
+ "application/json": {
2299
+ "schema": {
2300
+ "$ref": "#/components/schemas/RunsCancel"
2301
+ }
2302
+ }
2303
+ },
2304
+ "required": true
2305
+ },
2306
+ "responses": {
2307
+ "204": {
2308
+ "description": "Success - Runs cancelled"
2309
+ },
2310
+ "404": {
2311
+ "description": "Not Found",
2312
+ "content": {
2313
+ "application/json": {
2314
+ "schema": {
2315
+ "$ref": "#/components/schemas/ErrorResponse"
2316
+ }
2317
+ }
2318
+ }
2319
+ },
2320
+ "422": {
2321
+ "description": "Validation Error",
2322
+ "content": {
2323
+ "application/json": {
2324
+ "schema": {
2325
+ "$ref": "#/components/schemas/ErrorResponse"
2326
+ }
2327
+ }
2328
+ }
2329
+ }
2330
+ }
2331
+ }
2332
+ },
2271
2333
  "/runs/wait": {
2272
2334
  "post": {
2273
2335
  "tags": [
@@ -3959,6 +4021,23 @@
3959
4021
  "description": "How to handle duplicate creation. Must be either 'raise' (raise error if duplicate), or 'do_nothing' (return existing thread).",
3960
4022
  "default": "raise"
3961
4023
  },
4024
+ "ttl": {
4025
+ "type": "object",
4026
+ "title": "TTL",
4027
+ "description": "The time-to-live for the thread.",
4028
+ "properties": {
4029
+ "strategy": {
4030
+ "type": "string",
4031
+ "enum": ["delete"],
4032
+ "description": "The TTL strategy. 'delete' removes the entire thread.",
4033
+ "default": "delete"
4034
+ },
4035
+ "ttl": {
4036
+ "type": "number",
4037
+ "description": "The time-to-live in minutes from now until thread should be swept."
4038
+ }
4039
+ }
4040
+ },
3962
4041
  "supersteps": {
3963
4042
  "type": "array",
3964
4043
  "items": {
@@ -4398,6 +4477,42 @@
4398
4477
  },
4399
4478
  "description": "Represents a single document or data entry in the graph's Store. Items are used to store cross-thread memories."
4400
4479
  },
4480
+ "RunsCancel": {
4481
+ "type": "object",
4482
+ "title": "RunsCancel",
4483
+ "description": "Payload for cancelling runs.",
4484
+ "properties": {
4485
+ "status": {
4486
+ "type": "string",
4487
+ "enum": ["pending", "running", "all"],
4488
+ "title": "Status",
4489
+ "description": "Filter runs by status to cancel. Must be one of 'pending', 'running', or 'all'."
4490
+ },
4491
+ "thread_id": {
4492
+ "type": "string",
4493
+ "format": "uuid",
4494
+ "title": "Thread Id",
4495
+ "description": "The ID of the thread containing runs to cancel."
4496
+ },
4497
+ "run_ids": {
4498
+ "type": "array",
4499
+ "items": {
4500
+ "type": "string",
4501
+ "format": "uuid"
4502
+ },
4503
+ "title": "Run Ids",
4504
+ "description": "List of run IDs to cancel."
4505
+ }
4506
+ },
4507
+ "oneOf": [
4508
+ {
4509
+ "required": ["status"]
4510
+ },
4511
+ {
4512
+ "required": ["thread_id", "run_ids"]
4513
+ }
4514
+ ]
4515
+ },
4401
4516
  "SearchItemsResponse": {
4402
4517
  "type": "object",
4403
4518
  "required": [
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "langgraph-api"
3
- version = "0.0.33"
3
+ version = "0.0.36"
4
4
  description = ""
5
5
  authors = [
6
6
  "Nuno Campos <nuno@langchain.dev>",
@@ -24,7 +24,7 @@ sse-starlette = ">=2.1.0,<2.2.0"
24
24
  starlette = ">=0.38.6"
25
25
  watchfiles = ">=0.13"
26
26
  langgraph = ">=0.2.56,<0.4.0"
27
- langgraph-checkpoint = ">=2.0.21,<3.0"
27
+ langgraph-checkpoint = ">=2.0.23,<3.0"
28
28
  orjson = ">=3.9.7"
29
29
  uvicorn = ">=0.26.0"
30
30
  langsmith = ">=0.1.63,<0.4.0"
File without changes
File without changes