langgraph-api 0.3.1__tar.gz → 0.4.0__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 (120) hide show
  1. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/Makefile +4 -1
  2. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/PKG-INFO +1 -1
  3. langgraph_api-0.4.0/langgraph_api/__init__.py +1 -0
  4. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/api/meta.py +8 -1
  5. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/api/runs.py +13 -8
  6. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/api/threads.py +24 -0
  7. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/config.py +2 -0
  8. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/executor_entrypoint.py +3 -0
  9. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/models/run.py +5 -4
  10. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/queue_entrypoint.py +62 -64
  11. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/stream.py +4 -0
  12. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/utils/__init__.py +19 -0
  13. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/worker.py +4 -2
  14. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/openapi.json +67 -0
  15. langgraph_api-0.3.1/langgraph_api/__init__.py +0 -1
  16. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/.gitignore +0 -0
  17. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/LICENSE +0 -0
  18. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/README.md +0 -0
  19. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/benchmark/.gitignore +0 -0
  20. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/benchmark/Makefile +0 -0
  21. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/benchmark/README.md +0 -0
  22. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/benchmark/burst.js +0 -0
  23. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/benchmark/clean.js +0 -0
  24. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/benchmark/graphs.js +0 -0
  25. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/benchmark/package.json +0 -0
  26. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/benchmark/ramp.js +0 -0
  27. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/benchmark/update-revision.js +0 -0
  28. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/benchmark/weather.js +0 -0
  29. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/constraints.txt +0 -0
  30. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/forbidden.txt +0 -0
  31. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/healthcheck.py +0 -0
  32. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/api/__init__.py +0 -0
  33. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/api/assistants.py +0 -0
  34. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/api/mcp.py +0 -0
  35. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/api/openapi.py +0 -0
  36. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/api/store.py +0 -0
  37. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/api/ui.py +0 -0
  38. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/asgi_transport.py +0 -0
  39. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/asyncio.py +0 -0
  40. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/auth/__init__.py +0 -0
  41. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/auth/custom.py +0 -0
  42. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/auth/langsmith/__init__.py +0 -0
  43. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/auth/langsmith/backend.py +0 -0
  44. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/auth/langsmith/client.py +0 -0
  45. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/auth/middleware.py +0 -0
  46. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/auth/noop.py +0 -0
  47. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/auth/studio_user.py +0 -0
  48. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/cli.py +0 -0
  49. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/command.py +0 -0
  50. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/cron_scheduler.py +0 -0
  51. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/errors.py +0 -0
  52. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/feature_flags.py +0 -0
  53. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/graph.py +0 -0
  54. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/http.py +0 -0
  55. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/http_metrics.py +0 -0
  56. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/.gitignore +0 -0
  57. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/.prettierrc +0 -0
  58. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/__init__.py +0 -0
  59. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/base.py +0 -0
  60. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/build.mts +0 -0
  61. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/client.http.mts +0 -0
  62. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/client.mts +0 -0
  63. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/errors.py +0 -0
  64. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/global.d.ts +0 -0
  65. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/package.json +0 -0
  66. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/remote.py +0 -0
  67. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/schema.py +0 -0
  68. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/src/graph.mts +0 -0
  69. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/src/load.hooks.mjs +0 -0
  70. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/src/preload.mjs +0 -0
  71. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/src/utils/files.mts +0 -0
  72. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/src/utils/importMap.mts +0 -0
  73. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/src/utils/pythonSchemas.mts +0 -0
  74. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/src/utils/serde.mts +0 -0
  75. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/sse.py +0 -0
  76. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/traceblock.mts +0 -0
  77. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/tsconfig.json +0 -0
  78. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/ui.py +0 -0
  79. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/js/yarn.lock +0 -0
  80. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/logging.py +0 -0
  81. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/metadata.py +0 -0
  82. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/middleware/__init__.py +0 -0
  83. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/middleware/http_logger.py +0 -0
  84. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/middleware/private_network.py +0 -0
  85. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/middleware/request_id.py +0 -0
  86. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/models/__init__.py +0 -0
  87. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/patch.py +0 -0
  88. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/route.py +0 -0
  89. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/schema.py +0 -0
  90. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/serde.py +0 -0
  91. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/server.py +0 -0
  92. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/sse.py +0 -0
  93. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/state.py +0 -0
  94. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/store.py +0 -0
  95. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/thread_ttl.py +0 -0
  96. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/traceblock.py +0 -0
  97. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/tunneling/cloudflare.py +0 -0
  98. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/utils/cache.py +0 -0
  99. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/utils/config.py +0 -0
  100. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/utils/future.py +0 -0
  101. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/utils/headers.py +0 -0
  102. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/utils/uuids.py +0 -0
  103. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/utils.py +0 -0
  104. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/validation.py +0 -0
  105. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_api/webhook.py +0 -0
  106. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_license/__init__.py +0 -0
  107. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_license/validation.py +0 -0
  108. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_runtime/__init__.py +0 -0
  109. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_runtime/checkpoint.py +0 -0
  110. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_runtime/database.py +0 -0
  111. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_runtime/lifespan.py +0 -0
  112. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_runtime/metrics.py +0 -0
  113. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_runtime/ops.py +0 -0
  114. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_runtime/queue.py +0 -0
  115. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_runtime/retry.py +0 -0
  116. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/langgraph_runtime/store.py +0 -0
  117. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/logging.json +0 -0
  118. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/pyproject.toml +0 -0
  119. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/scripts/create_license.py +0 -0
  120. {langgraph_api-0.3.1 → langgraph_api-0.4.0}/uv.lock +0 -0
@@ -1,4 +1,4 @@
1
- .PHONY: build release lint format test test_watch start start-inmem start-inmem-license-oss start check-version
1
+ .PHONY: build release lint format test test_watch start start-inmem start-inmem-license-oss start check-version check-base-imports
2
2
 
3
3
  # lint commands
4
4
 
@@ -11,6 +11,9 @@ format:
11
11
  uv run ruff check --fix .
12
12
  uv run ruff format .
13
13
 
14
+ check-base-imports:
15
+ LANGGRAPH_RUNTIME_EDITION=inmem DATABASE_URI=:memory: REDIS_URI=_FAKE uv run python -c "from langgraph_api.config import *; from langgraph_runtime import *"
16
+
14
17
  # test commands
15
18
 
16
19
  TEST ?= tests/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-api
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Author-email: Nuno Campos <nuno@langchain.dev>, Will Fu-Hinthorn <will@langchain.dev>
5
5
  License: Elastic-2.0
6
6
  License-File: LICENSE
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -54,10 +54,16 @@ async def meta_metrics(request: ApiRequest):
54
54
  metadata.PROJECT_ID, metadata.HOST_REVISION_ID, metrics_format
55
55
  )
56
56
 
57
+ pg_redis_stats = pool_stats(
58
+ project_id=metadata.PROJECT_ID,
59
+ revision_id=metadata.HOST_REVISION_ID,
60
+ format=metrics_format,
61
+ )
62
+
57
63
  if metrics_format == "json":
58
64
  async with connect() as conn:
59
65
  resp = {
60
- **pool_stats(),
66
+ **pg_redis_stats,
61
67
  "queue": await Runs.stats(conn),
62
68
  **http_metrics,
63
69
  }
@@ -93,6 +99,7 @@ async def meta_metrics(request: ApiRequest):
93
99
  )
94
100
 
95
101
  metrics.extend(http_metrics)
102
+ metrics.extend(pg_redis_stats)
96
103
 
97
104
  metrics_response = "\n".join(metrics)
98
105
  return PlainTextResponse(metrics_response)
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  from collections.abc import AsyncIterator
3
3
  from typing import Literal, cast
4
+ from uuid import uuid4
4
5
 
5
6
  import orjson
6
7
  from starlette.exceptions import HTTPException
@@ -100,7 +101,7 @@ async def stream_run(
100
101
  payload = await request.json(RunCreateStateful)
101
102
  on_disconnect = payload.get("on_disconnect", "continue")
102
103
  run_id = uuid7()
103
- sub = asyncio.create_task(Runs.Stream.subscribe(run_id))
104
+ sub = asyncio.create_task(Runs.Stream.subscribe(run_id, thread_id))
104
105
 
105
106
  try:
106
107
  async with connect() as conn:
@@ -138,19 +139,21 @@ async def stream_run_stateless(
138
139
  ):
139
140
  """Create a stateless run."""
140
141
  payload = await request.json(RunCreateStateless)
142
+ payload["if_not_exists"] = "create"
141
143
  on_disconnect = payload.get("on_disconnect", "continue")
142
144
  run_id = uuid7()
143
- sub = asyncio.create_task(Runs.Stream.subscribe(run_id))
144
-
145
+ thread_id = uuid4()
146
+ sub = asyncio.create_task(Runs.Stream.subscribe(run_id, thread_id))
145
147
  try:
146
148
  async with connect() as conn:
147
149
  run = await create_valid_run(
148
150
  conn,
149
- None,
151
+ str(thread_id),
150
152
  payload,
151
153
  request.headers,
152
154
  run_id=run_id,
153
155
  request_start_time=request.scope.get("request_start_time_ms"),
156
+ temporary=True,
154
157
  )
155
158
  except Exception:
156
159
  if not sub.cancelled():
@@ -181,7 +184,7 @@ async def wait_run(request: ApiRequest):
181
184
  payload = await request.json(RunCreateStateful)
182
185
  on_disconnect = payload.get("on_disconnect", "continue")
183
186
  run_id = uuid7()
184
- sub = asyncio.create_task(Runs.Stream.subscribe(run_id))
187
+ sub = asyncio.create_task(Runs.Stream.subscribe(run_id, thread_id))
185
188
 
186
189
  try:
187
190
  async with connect() as conn:
@@ -263,26 +266,28 @@ async def wait_run(request: ApiRequest):
263
266
  async def wait_run_stateless(request: ApiRequest):
264
267
  """Create a stateless run, wait for the output."""
265
268
  payload = await request.json(RunCreateStateless)
269
+ payload["if_not_exists"] = "create"
266
270
  on_disconnect = payload.get("on_disconnect", "continue")
267
271
  run_id = uuid7()
268
- sub = asyncio.create_task(Runs.Stream.subscribe(run_id))
272
+ thread_id = uuid4()
273
+ sub = asyncio.create_task(Runs.Stream.subscribe(run_id, thread_id))
269
274
 
270
275
  try:
271
276
  async with connect() as conn:
272
277
  run = await create_valid_run(
273
278
  conn,
274
- None,
279
+ str(thread_id),
275
280
  payload,
276
281
  request.headers,
277
282
  run_id=run_id,
278
283
  request_start_time=request.scope.get("request_start_time_ms"),
284
+ temporary=True,
279
285
  )
280
286
  except Exception:
281
287
  if not sub.cancelled():
282
288
  handle = await sub
283
289
  await handle.__aexit__(None, None, None)
284
290
  raise
285
-
286
291
  last_chunk = ValueEvent()
287
292
 
288
293
  async def consume():
@@ -6,11 +6,13 @@ from starlette.routing import BaseRoute
6
6
 
7
7
  from langgraph_api.route import ApiRequest, ApiResponse, ApiRoute
8
8
  from langgraph_api.schema import THREAD_FIELDS
9
+ from langgraph_api.sse import EventSourceResponse
9
10
  from langgraph_api.state import state_snapshot_to_thread_state
10
11
  from langgraph_api.utils import (
11
12
  fetchone,
12
13
  get_pagination_headers,
13
14
  validate_select_columns,
15
+ validate_stream_id,
14
16
  validate_uuid,
15
17
  )
16
18
  from langgraph_api.validation import (
@@ -282,6 +284,23 @@ async def copy_thread(request: ApiRequest):
282
284
  return ApiResponse(await fetchone(iter, not_found_code=409))
283
285
 
284
286
 
287
+ @retry_db
288
+ async def join_thread_stream(request: ApiRequest):
289
+ """Join a thread stream."""
290
+ thread_id = request.path_params["thread_id"]
291
+ validate_uuid(thread_id, "Invalid thread ID: must be a UUID")
292
+ last_event_id = request.headers.get("last-event-id") or None
293
+ validate_stream_id(
294
+ last_event_id, "Invalid last-event-id: must be a valid Redis stream ID"
295
+ )
296
+ return EventSourceResponse(
297
+ Threads.Stream.join(
298
+ thread_id,
299
+ last_event_id=last_event_id,
300
+ ),
301
+ )
302
+
303
+
285
304
  threads_routes: list[BaseRoute] = [
286
305
  ApiRoute("/threads", endpoint=create_thread, methods=["POST"]),
287
306
  ApiRoute("/threads/search", endpoint=search_threads, methods=["POST"]),
@@ -312,4 +331,9 @@ threads_routes: list[BaseRoute] = [
312
331
  endpoint=get_thread_state_at_checkpoint_post,
313
332
  methods=["POST"],
314
333
  ),
334
+ ApiRoute(
335
+ "/threads/{thread_id}/stream",
336
+ endpoint=join_thread_stream,
337
+ methods=["GET"],
338
+ ),
315
339
  ]
@@ -180,6 +180,7 @@ REDIS_CLUSTER = env("REDIS_CLUSTER", cast=bool, default=False)
180
180
  REDIS_MAX_CONNECTIONS = env("REDIS_MAX_CONNECTIONS", cast=int, default=2000)
181
181
  REDIS_CONNECT_TIMEOUT = env("REDIS_CONNECT_TIMEOUT", cast=float, default=10.0)
182
182
  REDIS_MAX_IDLE_TIME = env("REDIS_MAX_IDLE_TIME", cast=float, default=120.0)
183
+ REDIS_STREAM_TIMEOUT = env("REDIS_STREAM_TIMEOUT", cast=float, default=30.0)
183
184
  REDIS_KEY_PREFIX = env("REDIS_KEY_PREFIX", cast=str, default="")
184
185
  RUN_STATS_CACHE_SECONDS = env("RUN_STATS_CACHE_SECONDS", cast=int, default=60)
185
186
 
@@ -374,6 +375,7 @@ API_VARIANT = env("LANGSMITH_LANGGRAPH_API_VARIANT", cast=str, default="")
374
375
  # UI
375
376
  UI_USE_BUNDLER = env("LANGGRAPH_UI_BUNDLER", cast=bool, default=False)
376
377
  IS_QUEUE_ENTRYPOINT = False
378
+ IS_EXECUTOR_ENTRYPOINT = False
377
379
  ref_sha = None
378
380
  if not os.getenv("LANGCHAIN_REVISION_ID") and (
379
381
  ref_sha := os.getenv("LANGSMITH_LANGGRAPH_GIT_REF_SHA")
@@ -20,4 +20,7 @@ if __name__ == "__main__":
20
20
  uvloop.install()
21
21
  except ImportError:
22
22
  pass
23
+ from langgraph_api import config
24
+
25
+ config.IS_EXECUTOR_ENTRYPOINT = True
23
26
  asyncio.run(main(grpc_port=args.grpc_port, entrypoint_name="python-executor"))
@@ -249,6 +249,7 @@ async def create_valid_run(
249
249
  barrier: asyncio.Barrier | None = None,
250
250
  run_id: UUID | None = None,
251
251
  request_start_time: float | None = None,
252
+ temporary: bool = False,
252
253
  ) -> Run:
253
254
  request_id = headers.get("x-request-id") # Will be null in the crons scheduler.
254
255
  (
@@ -262,7 +263,7 @@ async def create_valid_run(
262
263
  run_id=run_id,
263
264
  )
264
265
  if (
265
- thread_id_ is None
266
+ (thread_id_ is None or temporary)
266
267
  and (command := payload.get("command"))
267
268
  and command.get("resume")
268
269
  ):
@@ -270,9 +271,9 @@ async def create_valid_run(
270
271
  status_code=400,
271
272
  detail="You must provide a thread_id when resuming.",
272
273
  )
273
- temporary = (
274
- thread_id_ is None and payload.get("on_completion", "delete") == "delete"
275
- )
274
+ temporary = (temporary or thread_id_ is None) and payload.get(
275
+ "on_completion", "delete"
276
+ ) == "delete"
276
277
  stream_resumable = payload.get("stream_resumable", False)
277
278
  stream_mode, multitask_strategy, prevent_insert_if_inflight = assign_defaults(
278
279
  payload
@@ -11,10 +11,8 @@ if not (
11
11
 
12
12
  import asyncio
13
13
  import contextlib
14
- import http.server
15
14
  import json
16
15
  import logging.config
17
- import os
18
16
  import pathlib
19
17
  import signal
20
18
  from contextlib import asynccontextmanager
@@ -22,6 +20,7 @@ from typing import cast
22
20
 
23
21
  import structlog
24
22
 
23
+ from langgraph_runtime.database import pool_stats
25
24
  from langgraph_runtime.lifespan import lifespan
26
25
  from langgraph_runtime.metrics import get_metrics
27
26
 
@@ -29,69 +28,68 @@ logger = structlog.stdlib.get_logger(__name__)
29
28
 
30
29
 
31
30
  async def health_and_metrics_server():
31
+ import uvicorn
32
+ from starlette.applications import Starlette
33
+ from starlette.responses import JSONResponse, PlainTextResponse
34
+ from starlette.routing import Route
35
+
32
36
  port = int(os.getenv("PORT", "8080"))
33
- ok = json.dumps({"status": "ok"}).encode()
34
- ok_len = str(len(ok))
35
-
36
- class HealthAndMetricsHandler(http.server.SimpleHTTPRequestHandler):
37
- def log_message(self, format, *args):
38
- # Skip logging for /ok and /metrics endpoints
39
- if getattr(self, "path", None) in ["/ok", "/metrics"]:
40
- return
41
- # Log other requests normally
42
- super().log_message(format, *args)
43
-
44
- def do_GET(self):
45
- path = getattr(self, "path", None)
46
- if path == "/ok":
47
- self.send_response(200)
48
- self.send_header("Content-Type", "application/json")
49
- self.send_header("Content-Length", ok_len)
50
- self.end_headers()
51
- self.wfile.write(ok)
52
- elif path == "/metrics":
53
- metrics = get_metrics()
54
- worker_metrics = cast(dict[str, int], metrics["workers"])
55
- workers_max = worker_metrics["max"]
56
- workers_active = worker_metrics["active"]
57
- workers_available = worker_metrics["available"]
58
-
59
- project_id = os.getenv("LANGSMITH_HOST_PROJECT_ID")
60
- revision_id = os.getenv("LANGSMITH_HOST_REVISION_ID")
61
-
62
- metrics = [
63
- "# HELP lg_api_workers_max The maximum number of workers available.",
64
- "# TYPE lg_api_workers_max gauge",
65
- f'lg_api_workers_max{{project_id="{project_id}", revision_id="{revision_id}"}} {workers_max}',
66
- "# HELP lg_api_workers_active The number of currently active workers.",
67
- "# TYPE lg_api_workers_active gauge",
68
- f'lg_api_workers_active{{project_id="{project_id}", revision_id="{revision_id}"}} {workers_active}',
69
- "# HELP lg_api_workers_available The number of available (idle) workers.",
70
- "# TYPE lg_api_workers_available gauge",
71
- f'lg_api_workers_available{{project_id="{project_id}", revision_id="{revision_id}"}} {workers_available}',
72
- ]
73
-
74
- metrics_response = "\n".join(metrics).encode()
75
- metrics_len = str(len(metrics_response))
76
-
77
- self.send_response(200)
78
- self.send_header(
79
- "Content-Type", "text/plain; version=0.0.4; charset=utf-8"
80
- )
81
- self.send_header("Content-Length", metrics_len)
82
- self.end_headers()
83
- self.wfile.write(metrics_response)
84
- else:
85
- self.send_error(http.HTTPStatus.NOT_FOUND)
86
-
87
- with http.server.ThreadingHTTPServer(
88
- ("0.0.0.0", port), HealthAndMetricsHandler
89
- ) as httpd:
90
- logger.info(f"Health and metrics server started at http://0.0.0.0:{port}")
91
- try:
92
- await asyncio.to_thread(httpd.serve_forever)
93
- finally:
94
- httpd.shutdown()
37
+
38
+ async def health_endpoint(request):
39
+ return JSONResponse({"status": "ok"})
40
+
41
+ async def metrics_endpoint(request):
42
+ metrics = get_metrics()
43
+ worker_metrics = cast(dict[str, int], metrics["workers"])
44
+ workers_max = worker_metrics["max"]
45
+ workers_active = worker_metrics["active"]
46
+ workers_available = worker_metrics["available"]
47
+
48
+ project_id = os.getenv("LANGSMITH_HOST_PROJECT_ID")
49
+ revision_id = os.getenv("LANGSMITH_HOST_REVISION_ID")
50
+
51
+ metrics_lines = [
52
+ "# HELP lg_api_workers_max The maximum number of workers available.",
53
+ "# TYPE lg_api_workers_max gauge",
54
+ f'lg_api_workers_max{{project_id="{project_id}", revision_id="{revision_id}"}} {workers_max}',
55
+ "# HELP lg_api_workers_active The number of currently active workers.",
56
+ "# TYPE lg_api_workers_active gauge",
57
+ f'lg_api_workers_active{{project_id="{project_id}", revision_id="{revision_id}"}} {workers_active}',
58
+ "# HELP lg_api_workers_available The number of available (idle) workers.",
59
+ "# TYPE lg_api_workers_available gauge",
60
+ f'lg_api_workers_available{{project_id="{project_id}", revision_id="{revision_id}"}} {workers_available}',
61
+ ]
62
+
63
+ metrics_lines.extend(
64
+ pool_stats(
65
+ project_id=project_id,
66
+ revision_id=revision_id,
67
+ )
68
+ )
69
+
70
+ return PlainTextResponse(
71
+ "\n".join(metrics_lines),
72
+ media_type="text/plain; version=0.0.4; charset=utf-8",
73
+ )
74
+
75
+ app = Starlette(
76
+ routes=[
77
+ Route("/ok", health_endpoint),
78
+ Route("/metrics", metrics_endpoint),
79
+ ]
80
+ )
81
+
82
+ config = uvicorn.Config(
83
+ app,
84
+ host="0.0.0.0",
85
+ port=port,
86
+ log_level="error",
87
+ access_log=False,
88
+ )
89
+ server = uvicorn.Server(config)
90
+
91
+ logger.info(f"Health and metrics server started at http://0.0.0.0:{port}")
92
+ await server.serve()
95
93
 
96
94
 
97
95
  async def entrypoint(
@@ -424,6 +424,8 @@ async def consume(
424
424
  run_id: str | uuid.UUID,
425
425
  resumable: bool = False,
426
426
  stream_modes: set[StreamMode] | None = None,
427
+ *,
428
+ thread_id: str | uuid.UUID | None = None,
427
429
  ) -> None:
428
430
  stream_modes = stream_modes or set()
429
431
  if "messages-tuple" in stream_modes:
@@ -437,6 +439,7 @@ async def consume(
437
439
  run_id,
438
440
  mode,
439
441
  await run_in_executor(None, json_dumpb, payload),
442
+ thread_id=thread_id,
440
443
  resumable=resumable and mode.split("|")[0] in stream_modes,
441
444
  )
442
445
  except Exception as e:
@@ -446,6 +449,7 @@ async def consume(
446
449
  run_id,
447
450
  "error",
448
451
  await run_in_executor(None, json_dumpb, e),
452
+ thread_id=thread_id,
449
453
  )
450
454
  raise e
451
455
 
@@ -1,4 +1,5 @@
1
1
  import contextvars
2
+ import re
2
3
  import uuid
3
4
  from collections.abc import AsyncIterator
4
5
  from contextlib import asynccontextmanager
@@ -22,6 +23,7 @@ Row: TypeAlias = dict[str, Any]
22
23
  AuthContext = contextvars.ContextVar[Auth.types.BaseAuthContext | None](
23
24
  "AuthContext", default=None
24
25
  )
26
+ STREAM_ID_PATTERN = re.compile(r"^\d+(-(\d+|\*))?$")
25
27
 
26
28
 
27
29
  @asynccontextmanager
@@ -101,6 +103,23 @@ def validate_uuid(uuid_str: str, invalid_uuid_detail: str | None) -> uuid.UUID:
101
103
  raise HTTPException(status_code=422, detail=invalid_uuid_detail) from None
102
104
 
103
105
 
106
+ def validate_stream_id(stream_id: str | None, invalid_stream_id_detail: str | None):
107
+ """
108
+ Validate Redis stream ID format.
109
+ Valid formats:
110
+ - timestamp-sequence (e.g., "1724342400000-0")
111
+ - timestamp-* (e.g., "1724342400000-*")
112
+ - timestamp only (e.g., "1724342400000")
113
+ - "-" (special case, represents the beginning of the stream, use if you want to replay all events)
114
+ """
115
+ if not stream_id or stream_id == "-":
116
+ return stream_id
117
+
118
+ if STREAM_ID_PATTERN.match(stream_id):
119
+ return stream_id
120
+ raise HTTPException(status_code=422, detail=invalid_stream_id_detail)
121
+
122
+
104
123
  def next_cron_date(schedule: str, base_time: datetime) -> datetime:
105
124
  import croniter # type: ignore[unresolved-import]
106
125
 
@@ -139,7 +139,9 @@ async def worker(
139
139
  stream_modes: set[StreamMode],
140
140
  ):
141
141
  try:
142
- await consume(stream, run_id, resumable, stream_modes)
142
+ await consume(
143
+ stream, run_id, resumable, stream_modes, thread_id=run["thread_id"]
144
+ )
143
145
  except Exception as e:
144
146
  if not isinstance(e, UserRollback | UserInterrupt):
145
147
  logger.exception(
@@ -151,7 +153,7 @@ async def worker(
151
153
  raise UserTimeout(e) from e
152
154
  raise
153
155
 
154
- async with Runs.enter(run_id, main_loop) as done:
156
+ async with Runs.enter(run_id, run["thread_id"], main_loop) as done:
155
157
  # attempt the run
156
158
  try:
157
159
  if attempt > BG_JOB_MAX_RETRIES:
@@ -1520,6 +1520,73 @@
1520
1520
  }
1521
1521
  }
1522
1522
  },
1523
+ "/threads/{thread_id}/stream": {
1524
+ "get": {
1525
+ "tags": [
1526
+ "Threads"
1527
+ ],
1528
+ "summary": "Join Thread Stream",
1529
+ "description": "This endpoint streams output in real-time from a thread. The stream will include the output of each run executed sequentially on the thread and will remain open indefinitely. It is the responsibility of the calling client to close the connection.",
1530
+ "operationId": "join_thread_stream_threads__thread_id__stream_get",
1531
+ "parameters": [
1532
+ {
1533
+ "description": "The ID of the thread.",
1534
+ "required": true,
1535
+ "schema": {
1536
+ "type": "string",
1537
+ "format": "uuid",
1538
+ "title": "Thread Id",
1539
+ "description": "The ID of the thread."
1540
+ },
1541
+ "name": "thread_id",
1542
+ "in": "path"
1543
+ },
1544
+ {
1545
+ "required": false,
1546
+ "schema": {
1547
+ "type": "string",
1548
+ "title": "Last Event ID",
1549
+ "description": "The ID of the last event received. Used to resume streaming from a specific point. Pass '-' to resume from the beginning."
1550
+ },
1551
+ "name": "Last-Event-ID",
1552
+ "in": "header"
1553
+ }
1554
+ ],
1555
+ "responses": {
1556
+ "200": {
1557
+ "description": "Success",
1558
+ "content": {
1559
+ "text/event-stream": {
1560
+ "schema": {
1561
+ "type": "string",
1562
+ "description": "The server will send a stream of events in SSE format.\n\n**Example event**:\n\nid: 1\n\nevent: message\n\ndata: {}"
1563
+ }
1564
+ }
1565
+ }
1566
+ },
1567
+ "404": {
1568
+ "description": "Not Found",
1569
+ "content": {
1570
+ "application/json": {
1571
+ "schema": {
1572
+ "$ref": "#/components/schemas/ErrorResponse"
1573
+ }
1574
+ }
1575
+ }
1576
+ },
1577
+ "422": {
1578
+ "description": "Validation Error",
1579
+ "content": {
1580
+ "application/json": {
1581
+ "schema": {
1582
+ "$ref": "#/components/schemas/ErrorResponse"
1583
+ }
1584
+ }
1585
+ }
1586
+ }
1587
+ }
1588
+ }
1589
+ },
1523
1590
  "/threads/{thread_id}/runs": {
1524
1591
  "get": {
1525
1592
  "tags": [
@@ -1 +0,0 @@
1
- __version__ = "0.3.1"
File without changes
File without changes
File without changes
File without changes