langgraph-api 0.5.4__py3-none-any.whl → 0.7.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. langgraph_api/__init__.py +1 -1
  2. langgraph_api/api/__init__.py +93 -27
  3. langgraph_api/api/a2a.py +36 -32
  4. langgraph_api/api/assistants.py +114 -26
  5. langgraph_api/api/mcp.py +3 -3
  6. langgraph_api/api/meta.py +15 -2
  7. langgraph_api/api/openapi.py +27 -17
  8. langgraph_api/api/profile.py +108 -0
  9. langgraph_api/api/runs.py +114 -57
  10. langgraph_api/api/store.py +19 -2
  11. langgraph_api/api/threads.py +133 -10
  12. langgraph_api/asgi_transport.py +14 -9
  13. langgraph_api/auth/custom.py +23 -13
  14. langgraph_api/cli.py +86 -41
  15. langgraph_api/command.py +2 -2
  16. langgraph_api/config/__init__.py +532 -0
  17. langgraph_api/config/_parse.py +58 -0
  18. langgraph_api/config/schemas.py +431 -0
  19. langgraph_api/cron_scheduler.py +17 -1
  20. langgraph_api/encryption/__init__.py +15 -0
  21. langgraph_api/encryption/aes_json.py +158 -0
  22. langgraph_api/encryption/context.py +35 -0
  23. langgraph_api/encryption/custom.py +280 -0
  24. langgraph_api/encryption/middleware.py +632 -0
  25. langgraph_api/encryption/shared.py +63 -0
  26. langgraph_api/errors.py +12 -1
  27. langgraph_api/executor_entrypoint.py +11 -6
  28. langgraph_api/feature_flags.py +19 -0
  29. langgraph_api/graph.py +163 -64
  30. langgraph_api/{grpc_ops → grpc}/client.py +142 -12
  31. langgraph_api/{grpc_ops → grpc}/config_conversion.py +16 -10
  32. langgraph_api/grpc/generated/__init__.py +29 -0
  33. langgraph_api/grpc/generated/checkpointer_pb2.py +63 -0
  34. langgraph_api/grpc/generated/checkpointer_pb2.pyi +99 -0
  35. langgraph_api/grpc/generated/checkpointer_pb2_grpc.py +329 -0
  36. langgraph_api/grpc/generated/core_api_pb2.py +216 -0
  37. langgraph_api/{grpc_ops → grpc}/generated/core_api_pb2.pyi +292 -372
  38. langgraph_api/{grpc_ops → grpc}/generated/core_api_pb2_grpc.py +252 -31
  39. langgraph_api/grpc/generated/engine_common_pb2.py +219 -0
  40. langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2.pyi +178 -104
  41. langgraph_api/grpc/generated/enum_cancel_run_action_pb2.py +37 -0
  42. langgraph_api/grpc/generated/enum_cancel_run_action_pb2.pyi +12 -0
  43. langgraph_api/grpc/generated/enum_cancel_run_action_pb2_grpc.py +24 -0
  44. langgraph_api/grpc/generated/enum_control_signal_pb2.py +37 -0
  45. langgraph_api/grpc/generated/enum_control_signal_pb2.pyi +16 -0
  46. langgraph_api/grpc/generated/enum_control_signal_pb2_grpc.py +24 -0
  47. langgraph_api/grpc/generated/enum_durability_pb2.py +37 -0
  48. langgraph_api/grpc/generated/enum_durability_pb2.pyi +16 -0
  49. langgraph_api/grpc/generated/enum_durability_pb2_grpc.py +24 -0
  50. langgraph_api/grpc/generated/enum_multitask_strategy_pb2.py +37 -0
  51. langgraph_api/grpc/generated/enum_multitask_strategy_pb2.pyi +16 -0
  52. langgraph_api/grpc/generated/enum_multitask_strategy_pb2_grpc.py +24 -0
  53. langgraph_api/grpc/generated/enum_run_status_pb2.py +37 -0
  54. langgraph_api/grpc/generated/enum_run_status_pb2.pyi +22 -0
  55. langgraph_api/grpc/generated/enum_run_status_pb2_grpc.py +24 -0
  56. langgraph_api/grpc/generated/enum_stream_mode_pb2.py +37 -0
  57. langgraph_api/grpc/generated/enum_stream_mode_pb2.pyi +28 -0
  58. langgraph_api/grpc/generated/enum_stream_mode_pb2_grpc.py +24 -0
  59. langgraph_api/grpc/generated/enum_thread_status_pb2.py +37 -0
  60. langgraph_api/grpc/generated/enum_thread_status_pb2.pyi +16 -0
  61. langgraph_api/grpc/generated/enum_thread_status_pb2_grpc.py +24 -0
  62. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.py +37 -0
  63. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.pyi +16 -0
  64. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2_grpc.py +24 -0
  65. langgraph_api/grpc/generated/errors_pb2.py +39 -0
  66. langgraph_api/grpc/generated/errors_pb2.pyi +21 -0
  67. langgraph_api/grpc/generated/errors_pb2_grpc.py +24 -0
  68. langgraph_api/grpc/ops/__init__.py +370 -0
  69. langgraph_api/grpc/ops/assistants.py +424 -0
  70. langgraph_api/grpc/ops/runs.py +792 -0
  71. langgraph_api/grpc/ops/threads.py +1013 -0
  72. langgraph_api/http.py +16 -5
  73. langgraph_api/js/client.mts +1 -4
  74. langgraph_api/js/package.json +28 -27
  75. langgraph_api/js/remote.py +39 -17
  76. langgraph_api/js/sse.py +2 -2
  77. langgraph_api/js/ui.py +1 -1
  78. langgraph_api/js/yarn.lock +1139 -869
  79. langgraph_api/metadata.py +29 -3
  80. langgraph_api/middleware/http_logger.py +1 -1
  81. langgraph_api/middleware/private_network.py +7 -7
  82. langgraph_api/models/run.py +44 -26
  83. langgraph_api/otel_context.py +205 -0
  84. langgraph_api/patch.py +2 -2
  85. langgraph_api/queue_entrypoint.py +34 -35
  86. langgraph_api/route.py +33 -1
  87. langgraph_api/schema.py +84 -9
  88. langgraph_api/self_hosted_logs.py +2 -2
  89. langgraph_api/self_hosted_metrics.py +73 -3
  90. langgraph_api/serde.py +16 -4
  91. langgraph_api/server.py +33 -31
  92. langgraph_api/state.py +3 -2
  93. langgraph_api/store.py +25 -16
  94. langgraph_api/stream.py +20 -16
  95. langgraph_api/thread_ttl.py +28 -13
  96. langgraph_api/timing/__init__.py +25 -0
  97. langgraph_api/timing/profiler.py +200 -0
  98. langgraph_api/timing/timer.py +318 -0
  99. langgraph_api/utils/__init__.py +53 -8
  100. langgraph_api/utils/config.py +2 -1
  101. langgraph_api/utils/future.py +10 -6
  102. langgraph_api/utils/uuids.py +29 -62
  103. langgraph_api/validation.py +6 -0
  104. langgraph_api/webhook.py +120 -6
  105. langgraph_api/worker.py +54 -24
  106. {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/METADATA +8 -6
  107. langgraph_api-0.7.3.dist-info/RECORD +168 -0
  108. {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/WHEEL +1 -1
  109. langgraph_runtime/__init__.py +1 -0
  110. langgraph_runtime/routes.py +11 -0
  111. logging.json +1 -3
  112. openapi.json +635 -537
  113. langgraph_api/config.py +0 -523
  114. langgraph_api/grpc_ops/generated/__init__.py +0 -5
  115. langgraph_api/grpc_ops/generated/core_api_pb2.py +0 -275
  116. langgraph_api/grpc_ops/generated/engine_common_pb2.py +0 -194
  117. langgraph_api/grpc_ops/ops.py +0 -1045
  118. langgraph_api-0.5.4.dist-info/RECORD +0 -121
  119. /langgraph_api/{grpc_ops → grpc}/__init__.py +0 -0
  120. /langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2_grpc.py +0 -0
  121. {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/entry_points.txt +0 -0
  122. {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/licenses/LICENSE +0 -0
langgraph_api/schema.py CHANGED
@@ -16,7 +16,15 @@ RunStatus = Literal["pending", "running", "error", "success", "timeout", "interr
16
16
  ThreadStatus = Literal["idle", "busy", "interrupted", "error"]
17
17
 
18
18
  StreamMode = Literal[
19
- "values", "messages", "updates", "events", "debug", "tasks", "checkpoints", "custom"
19
+ "values",
20
+ "messages",
21
+ "updates",
22
+ "events",
23
+ "debug",
24
+ "tasks",
25
+ "checkpoints",
26
+ "custom",
27
+ "messages-tuple",
20
28
  ]
21
29
 
22
30
  ThreadStreamMode = Literal["lifecycle", "run_modes", "state_update"]
@@ -50,10 +58,16 @@ class Config(TypedDict, total=False):
50
58
  """
51
59
  Runtime values for attributes previously made configurable on this Runnable,
52
60
  or sub-Runnables, through .configurable_fields() or .configurable_alternatives().
53
- Check .output_schema() for a description of the attributes that have been made
61
+ Check .output_schema() for a description of the attributes that have been made
54
62
  configurable.
55
63
  """
56
64
 
65
+ __encryption_context__: dict[str, Any]
66
+ """
67
+ Internal: Encryption context for encryption/decryption operations.
68
+ Not exposed to users.
69
+ """
70
+
57
71
 
58
72
  class Checkpoint(TypedDict):
59
73
  thread_id: str
@@ -112,6 +126,17 @@ class DeprecatedInterrupt(TypedDict, total=False):
112
126
  """When the interrupt occurred, always "during"."""
113
127
 
114
128
 
129
+ class ThreadTTLInfo(TypedDict, total=False):
130
+ """TTL information for a thread. Only present when ?include=ttl is passed."""
131
+
132
+ strategy: Literal["delete", "keep_latest"]
133
+ """The TTL strategy."""
134
+ ttl_minutes: float
135
+ """The TTL in minutes."""
136
+ expires_at: datetime
137
+ """When the thread will expire."""
138
+
139
+
115
140
  class Thread(TypedDict):
116
141
  thread_id: UUID
117
142
  """The ID of the thread."""
@@ -123,14 +148,14 @@ class Thread(TypedDict):
123
148
  """The thread metadata."""
124
149
  config: Fragment
125
150
  """The thread config."""
126
- context: Fragment
127
- """The thread context."""
128
151
  status: ThreadStatus
129
152
  """The status of the thread. One of 'idle', 'busy', 'interrupted', "error"."""
130
153
  values: Fragment
131
154
  """The current state of the thread."""
132
155
  interrupts: dict[str, list[Interrupt]]
133
156
  """The current interrupts of the thread, a map of task_id to list of interrupts."""
157
+ ttl: NotRequired[ThreadTTLInfo]
158
+ """TTL information if set for this thread. Only present when ?include=ttl is passed."""
134
159
 
135
160
 
136
161
  class ThreadTask(TypedDict):
@@ -148,7 +173,7 @@ class ThreadState(TypedDict):
148
173
  next: Sequence[str]
149
174
  """The name of the node to execute in each task for this step."""
150
175
  checkpoint: Checkpoint
151
- """The checkpoint keys. This object can be passed to the /threads and /runs
176
+ """The checkpoint keys. This object can be passed to the /threads and /runs
152
177
  endpoints to resume execution or update state."""
153
178
  metadata: Fragment
154
179
  """Metadata for this state"""
@@ -220,6 +245,8 @@ class Cron(TypedDict):
220
245
  """The ID of the assistant."""
221
246
  thread_id: UUID | None
222
247
  """The ID of the thread."""
248
+ on_run_completed: NotRequired[Literal["delete", "keep"] | None]
249
+ """What to do with the thread after the run completes."""
223
250
  end_time: datetime | None
224
251
  """The end date to stop running the cron."""
225
252
  schedule: str
@@ -249,8 +276,9 @@ class ThreadUpdateResponse(TypedDict):
249
276
  class QueueStats(TypedDict):
250
277
  n_pending: int
251
278
  n_running: int
252
- max_age_secs: datetime | None
253
- med_age_secs: datetime | None
279
+ pending_runs_wait_time_max_secs: float | None
280
+ pending_runs_wait_time_med_secs: float | None
281
+ pending_unblocked_runs_wait_time_max_secs: float | None
254
282
 
255
283
 
256
284
  # Canonical field sets for select= validation and type aliases for ops
@@ -277,7 +305,6 @@ ThreadSelectField = Literal[
277
305
  "updated_at",
278
306
  "metadata",
279
307
  "config",
280
- "context",
281
308
  "status",
282
309
  "values",
283
310
  "interrupts",
@@ -303,6 +330,7 @@ CronSelectField = Literal[
303
330
  "cron_id",
304
331
  "assistant_id",
305
332
  "thread_id",
333
+ "on_run_completed",
306
334
  "end_time",
307
335
  "schedule",
308
336
  "created_at",
@@ -311,6 +339,53 @@ CronSelectField = Literal[
311
339
  "payload",
312
340
  "next_run_date",
313
341
  "metadata",
314
- "now",
315
342
  ]
316
343
  CRON_FIELDS: set[str] = set(CronSelectField.__args__) # type: ignore[attr-defined]
344
+
345
+ # Encryption field constants
346
+ # These define which fields are encrypted for each model type.
347
+ #
348
+ # Note: Checkpoint encryption (checkpoint, metadata columns in checkpoints table, plus
349
+ # blob data in checkpoint_blobs and checkpoint_writes) is handled directly by the
350
+ # Checkpointer class in storage_postgres/langgraph_runtime_postgres/checkpoint.py.
351
+ # The checkpointer uses encrypt_json_if_needed/decrypt_json_if_needed directly rather
352
+ # than the field list pattern used by the API middleware. This is because checkpoints
353
+ # are only accessed via the checkpointer's internal methods (aget_tuple, aput, etc.),
354
+ # not through generic API CRUD operations.
355
+
356
+ THREAD_ENCRYPTION_FIELDS = ["metadata", "config", "values", "interrupts", "error"]
357
+
358
+ # kwargs is a nested blob - its subfields are decrypted automatically by the middleware
359
+ RUN_ENCRYPTION_FIELDS = ["metadata", "kwargs"]
360
+
361
+ ASSISTANT_ENCRYPTION_FIELDS = ["metadata", "config", "context"]
362
+
363
+ # payload is a nested blob - its subfields are decrypted automatically by the middleware
364
+ CRON_ENCRYPTION_FIELDS = ["metadata", "payload"]
365
+
366
+ # Store encryption - only the value field contains user data
367
+ STORE_ENCRYPTION_FIELDS = ["value"]
368
+
369
+ # The middleware automatically decrypts these subfields when decrypting the parent field.
370
+ # This is recursive: if a subfield is also in NESTED_ENCRYPTED_SUBFIELDS, its subfields
371
+ # are decrypted too (e.g., run.kwargs.config.configurable).
372
+ NESTED_ENCRYPTED_SUBFIELDS: dict[tuple[str, str], list[str]] = {
373
+ ("run", "kwargs"): ["input", "config", "context", "command"],
374
+ ("run", "config"): ["configurable", "metadata"],
375
+ ("cron", "payload"): ["metadata", "context", "input", "config"],
376
+ ("cron", "config"): ["configurable", "metadata"],
377
+ ("assistant", "config"): ["configurable"],
378
+ }
379
+
380
+ # Convenience alias for cron payload subfields.
381
+ #
382
+ # This is a reflection of an unfortunate asymmetry in cron's data model.
383
+ #
384
+ # The cron API requests have payload fields (metadata, input, config, context) at the
385
+ # top level, but at rest they're nested inside the `payload` JSONB column (with
386
+ # metadata also duplicated as a top-level column). This alias is used to encrypt
387
+ # those fields in the flat request before storage.
388
+ CRON_PAYLOAD_ENCRYPTION_SUBFIELDS = NESTED_ENCRYPTED_SUBFIELDS[("cron", "payload")]
389
+
390
+ # Convenience alias for run kwargs subfields, used by the worker for decryption.
391
+ RUN_KWARGS_ENCRYPTION_SUBFIELDS = NESTED_ENCRYPTED_SUBFIELDS[("run", "kwargs")]
@@ -20,7 +20,7 @@ _customer_attributes = {}
20
20
 
21
21
  # see https://github.com/open-telemetry/opentelemetry-python/issues/3649 for why we need this
22
22
  class AttrFilteredLoggingHandler(LoggingHandler):
23
- DROP_ATTRIBUTES = ["_logger"]
23
+ DROP_ATTRIBUTES = ("_logger",)
24
24
 
25
25
  @staticmethod
26
26
  def _get_attributes(record: logging.LogRecord) -> Attributes:
@@ -32,7 +32,7 @@ class AttrFilteredLoggingHandler(LoggingHandler):
32
32
  }
33
33
  if _customer_attributes:
34
34
  attributes.update(_customer_attributes)
35
- return cast(Attributes, attributes)
35
+ return cast("Attributes", attributes)
36
36
 
37
37
 
38
38
  def initialize_self_hosted_logs() -> None:
@@ -12,11 +12,15 @@ from opentelemetry.sdk.resources import SERVICE_NAME, Resource
12
12
 
13
13
  from langgraph_api import asyncio as lg_asyncio
14
14
  from langgraph_api import config, metadata
15
+ from langgraph_api.feature_flags import FF_USE_CORE_API
16
+ from langgraph_api.grpc.ops import Runs as GrpcRuns
15
17
  from langgraph_api.http_metrics_utils import HTTP_LATENCY_BUCKETS
16
18
  from langgraph_runtime.database import connect, pool_stats
17
19
  from langgraph_runtime.metrics import get_metrics
18
20
  from langgraph_runtime.ops import Runs
19
21
 
22
+ CrudRuns = GrpcRuns if FF_USE_CORE_API else Runs
23
+
20
24
  logger = structlog.stdlib.get_logger(__name__)
21
25
 
22
26
  _meter_provider = None
@@ -109,6 +113,27 @@ def initialize_self_hosted_metrics():
109
113
  callbacks=[_get_running_runs_callback],
110
114
  )
111
115
 
116
+ meter.create_observable_gauge(
117
+ name="lg_api_pending_runs_wait_time_max",
118
+ description="The maximum time a run has been pending, in seconds",
119
+ unit="s",
120
+ callbacks=[_get_pending_runs_wait_time_max_callback],
121
+ )
122
+
123
+ meter.create_observable_gauge(
124
+ name="lg_api_pending_runs_wait_time_med",
125
+ description="The median pending wait time across runs, in seconds",
126
+ unit="s",
127
+ callbacks=[_get_pending_runs_wait_time_med_callback],
128
+ )
129
+
130
+ meter.create_observable_gauge(
131
+ name="lg_api_pending_unblocked_runs_wait_time_max",
132
+ description="The maximum time a run has been pending excluding runs blocked by another run on the same thread, in seconds",
133
+ unit="s",
134
+ callbacks=[_get_pending_unblocked_runs_wait_time_max_callback],
135
+ )
136
+
112
137
  if config.N_JOBS_PER_WORKER > 0:
113
138
  meter.create_observable_gauge(
114
139
  name="lg_api_workers_max",
@@ -229,17 +254,29 @@ def _get_queue_stats():
229
254
  async def _fetch_queue_stats():
230
255
  try:
231
256
  async with connect() as conn:
232
- return await Runs.stats(conn)
257
+ return await CrudRuns.stats(conn)
233
258
  except Exception as e:
234
259
  logger.warning("Failed to get queue stats from database", exc_info=e)
235
- return {"n_pending": 0, "n_running": 0}
260
+ return {
261
+ "n_pending": 0,
262
+ "n_running": 0,
263
+ "pending_runs_wait_time_max_secs": 0,
264
+ "pending_runs_wait_time_med_secs": 0,
265
+ "pending_unblocked_runs_wait_time_max_secs": 0,
266
+ }
236
267
 
237
268
  try:
238
269
  future = lg_asyncio.run_coroutine_threadsafe(_fetch_queue_stats())
239
270
  return future.result(timeout=5)
240
271
  except Exception as e:
241
272
  logger.warning("Failed to get queue stats", exc_info=e)
242
- return {"n_pending": 0, "n_running": 0}
273
+ return {
274
+ "n_pending": 0,
275
+ "n_running": 0,
276
+ "pending_runs_wait_time_max_secs": 0,
277
+ "pending_runs_wait_time_med_secs": 0,
278
+ "pending_unblocked_runs_wait_time_max_secs": 0,
279
+ }
243
280
 
244
281
 
245
282
  def _get_pool_stats():
@@ -280,6 +317,39 @@ def _get_running_runs_callback(options: CallbackOptions):
280
317
  return [Observation(0, attributes=_customer_attributes)]
281
318
 
282
319
 
320
+ def _get_pending_runs_wait_time_max_callback(options: CallbackOptions):
321
+ try:
322
+ stats = _get_queue_stats()
323
+ value = stats.get("pending_runs_wait_time_max_secs")
324
+ value = 0 if value is None else value
325
+ return [Observation(value, attributes=_customer_attributes)]
326
+ except Exception as e:
327
+ logger.warning("Failed to get max pending wait time", exc_info=e)
328
+ return [Observation(0, attributes=_customer_attributes)]
329
+
330
+
331
+ def _get_pending_runs_wait_time_med_callback(options: CallbackOptions):
332
+ try:
333
+ stats = _get_queue_stats()
334
+ value = stats.get("pending_runs_wait_time_med_secs")
335
+ value = 0 if value is None else value
336
+ return [Observation(value, attributes=_customer_attributes)]
337
+ except Exception as e:
338
+ logger.warning("Failed to get median pending wait time", exc_info=e)
339
+ return [Observation(0, attributes=_customer_attributes)]
340
+
341
+
342
+ def _get_pending_unblocked_runs_wait_time_max_callback(options: CallbackOptions):
343
+ try:
344
+ stats = _get_queue_stats()
345
+ value = stats.get("pending_unblocked_runs_wait_time_max_secs")
346
+ value = 0 if value is None else value
347
+ return [Observation(value, attributes=_customer_attributes)]
348
+ except Exception as e:
349
+ logger.warning("Failed to get max unblocked pending wait time", exc_info=e)
350
+ return [Observation(0, attributes=_customer_attributes)]
351
+
352
+
283
353
  def _get_workers_max_callback(options: CallbackOptions):
284
354
  try:
285
355
  metrics_data = get_metrics()
langgraph_api/serde.py CHANGED
@@ -54,7 +54,7 @@ def decimal_encoder(dec_value: Decimal) -> int | float:
54
54
  # maps to float('nan') / float('inf') / float('-inf')
55
55
  not dec_value.is_finite()
56
56
  # or regular float
57
- or cast(int, dec_value.as_tuple().exponent) < 0
57
+ or cast("int", dec_value.as_tuple().exponent) < 0
58
58
  ):
59
59
  return float(dec_value)
60
60
  return int(dec_value)
@@ -79,15 +79,15 @@ def default(obj):
79
79
  return obj._asdict()
80
80
  elif isinstance(obj, BaseException):
81
81
  return {"error": type(obj).__name__, "message": str(obj)}
82
- elif isinstance(obj, (set, frozenset, deque)): # noqa: UP038
82
+ elif isinstance(obj, (set, frozenset, deque)):
83
83
  return list(obj)
84
- elif isinstance(obj, (timezone, ZoneInfo)): # noqa: UP038
84
+ elif isinstance(obj, (timezone, ZoneInfo)):
85
85
  return obj.tzname(None)
86
86
  elif isinstance(obj, timedelta):
87
87
  return obj.total_seconds()
88
88
  elif isinstance(obj, Decimal):
89
89
  return decimal_encoder(obj)
90
- elif isinstance( # noqa: UP038
90
+ elif isinstance(
91
91
  obj,
92
92
  (
93
93
  uuid.UUID,
@@ -160,6 +160,18 @@ def json_loads(content: bytes | Fragment | dict) -> Any:
160
160
  return orjson.loads(content)
161
161
 
162
162
 
163
+ def json_dumpb_optional(obj: Any | None) -> bytes | None:
164
+ if obj is None:
165
+ return
166
+ return json_dumpb(obj)
167
+
168
+
169
+ def json_loads_optional(content: bytes | None) -> Any | None:
170
+ if content is None:
171
+ return
172
+ return json_loads(content)
173
+
174
+
163
175
  # Do not use. orjson holds the GIL the entire time it's running anyway.
164
176
  async def ajson_loads(content: bytes | Fragment) -> Any:
165
177
  return await asyncio.to_thread(json_loads, content)
langgraph_api/server.py CHANGED
@@ -1,29 +1,28 @@
1
1
  # MONKEY PATCH: Patch Starlette to fix an error in the library
2
- # ruff: noqa: E402
3
- import langgraph_api.patch # noqa: F401,I001
4
- import sys
5
- import os
6
-
7
2
  # WARNING: Keep the import above before other code runs as it
8
3
  # patches an error in the Starlette library.
4
+ import langgraph_api.patch # noqa: F401,I001
5
+ import langgraph_api.timing as timing
9
6
  import logging
7
+ import os
8
+ import sys
10
9
  import typing
11
10
 
12
11
  if not (
13
12
  (disable_truststore := os.getenv("DISABLE_TRUSTSTORE"))
14
13
  and disable_truststore.lower() == "true"
15
14
  ):
16
- import truststore # noqa: F401
15
+ import truststore
17
16
 
18
- truststore.inject_into_ssl() # noqa: F401
17
+ truststore.inject_into_ssl()
19
18
 
20
- from contextlib import asynccontextmanager
21
19
 
22
20
  import jsonschema_rs
23
21
  import structlog
24
22
  from langgraph.errors import EmptyInputError, InvalidUpdateError
25
23
  from langgraph_sdk.client import configure_loopback_transports
26
24
  from starlette.applications import Starlette
25
+ from starlette.exceptions import HTTPException
27
26
  from starlette.middleware import Middleware
28
27
  from starlette.middleware.cors import CORSMiddleware
29
28
  from starlette.routing import BaseRoute, Mount
@@ -39,6 +38,7 @@ from langgraph_api.api import (
39
38
  )
40
39
  from langgraph_api.api.openapi import set_custom_spec
41
40
  from langgraph_api.errors import (
41
+ http_exception_handler,
42
42
  overloaded_error_handler,
43
43
  validation_error_handler,
44
44
  value_error_handler,
@@ -96,6 +96,7 @@ global_middleware.extend(
96
96
  ]
97
97
  )
98
98
  exception_handlers = {
99
+ HTTPException: http_exception_handler,
99
100
  ValueError: value_error_handler,
100
101
  InvalidUpdateError: value_error_handler,
101
102
  EmptyInputError: value_error_handler,
@@ -223,16 +224,7 @@ if user_router:
223
224
  f"Cannot merge lifespans with on_startup or on_shutdown: {app.router.on_startup} {app.router.on_shutdown}"
224
225
  )
225
226
 
226
- @asynccontextmanager
227
- async def combined_lifespan(app):
228
- async with lifespan(app):
229
- if user_lifespan:
230
- async with user_lifespan(app):
231
- yield
232
- else:
233
- yield
234
-
235
- app.router.lifespan_context = combined_lifespan
227
+ app.router.lifespan_context = timing.combine_lifespans(lifespan, user_lifespan)
236
228
 
237
229
  # Merge exception handlers (base + user)
238
230
  for k, v in exception_handlers.items():
@@ -240,24 +232,30 @@ if user_router:
240
232
  app.exception_handlers[k] = v
241
233
  else:
242
234
  logger.debug(f"Overriding exception handler for {k}")
243
- # If the user creates a loopback client with `get_client() (no url)
244
- # this will update the http transport to connect to the right app
245
- configure_loopback_transports(app)
246
235
  else:
247
236
  # It's a regular starlette app
248
237
  app = Starlette(
249
- routes=apply_middleware(
250
- unshadowable_meta_routes + shadowable_meta_routes,
251
- route_level_custom_middleware,
252
- )
253
- + [protected_mount],
254
- lifespan=lifespan,
238
+ routes=[
239
+ *apply_middleware(
240
+ unshadowable_meta_routes + shadowable_meta_routes,
241
+ route_level_custom_middleware,
242
+ ),
243
+ protected_mount,
244
+ ],
245
+ lifespan=timing.combine_lifespans(lifespan),
255
246
  middleware=global_middleware,
256
247
  exception_handlers=exception_handlers,
257
248
  )
258
249
 
250
+ # If the user creates a loopback client with `get_client() (no url)
251
+ # this will update the http transport to connect to the right app
252
+ configure_loopback_transports(app)
259
253
 
260
254
  if config.MOUNT_PREFIX:
255
+ from starlette.routing import Route
256
+
257
+ from langgraph_api.api import meta_metrics, ok
258
+
261
259
  prefix = config.MOUNT_PREFIX
262
260
  if not prefix.startswith("/") or prefix.endswith("/"):
263
261
  raise ValueError(
@@ -265,8 +263,6 @@ if config.MOUNT_PREFIX:
265
263
  f"Valid examples: '/my-api', '/v1', '/api/v1'.\nInvalid examples: 'api/', '/api/'"
266
264
  )
267
265
  logger.info(f"Mounting routes at prefix: {prefix}")
268
- plen = len(prefix)
269
- rplen = len(prefix.encode("utf-8"))
270
266
 
271
267
  class ASGIBypassMiddleware:
272
268
  def __init__(self, app: typing.Any, **kwargs):
@@ -284,9 +280,15 @@ if config.MOUNT_PREFIX:
284
280
 
285
281
  return await self.app(scope, receive, send)
286
282
 
283
+ # Add health checks at root still to avoid having to override health checks.
287
284
  app = Starlette(
288
- routes=[Mount(prefix, app=app)],
285
+ routes=[
286
+ Route("/", ok, methods=["GET"]),
287
+ Route("/ok", ok, methods=["GET"]),
288
+ Route("/metrics", meta_metrics, methods=["GET"]),
289
+ Mount(prefix, app=app),
290
+ ],
289
291
  lifespan=app.router.lifespan_context,
290
- middleware=[Middleware(ASGIBypassMiddleware)] + app.user_middleware,
292
+ middleware=[Middleware(ASGIBypassMiddleware)],
291
293
  exception_handlers=app.exception_handlers,
292
294
  )
langgraph_api/state.py CHANGED
@@ -6,12 +6,13 @@ from langgraph.types import Interrupt, StateSnapshot
6
6
 
7
7
  from langgraph_api.feature_flags import USE_NEW_INTERRUPTS
8
8
  from langgraph_api.js.base import RemoteInterrupt
9
- from langgraph_api.schema import Checkpoint, DeprecatedInterrupt, ThreadState
10
- from langgraph_api.schema import Interrupt as InterruptSchema
11
9
 
12
10
  if typing.TYPE_CHECKING:
13
11
  from langchain_core.runnables.config import RunnableConfig
14
12
 
13
+ from langgraph_api.schema import Checkpoint, DeprecatedInterrupt, ThreadState
14
+ from langgraph_api.schema import Interrupt as InterruptSchema
15
+
15
16
 
16
17
  def runnable_config_to_checkpoint(
17
18
  config: RunnableConfig | None,
langgraph_api/store.py CHANGED
@@ -12,7 +12,8 @@ from langgraph.graph import StateGraph
12
12
  from langgraph.pregel import Pregel
13
13
  from langgraph.store.base import BaseStore
14
14
 
15
- from langgraph_api import config
15
+ from langgraph_api import config, timing
16
+ from langgraph_api.timing import profiled_import
16
17
  from langgraph_api.utils.config import run_in_executor
17
18
 
18
19
  logger = structlog.stdlib.get_logger(__name__)
@@ -83,22 +84,30 @@ async def collect_store_from_env() -> None:
83
84
  CUSTOM_STORE = value
84
85
 
85
86
 
87
+ @timing.timer(
88
+ message="Loading store {store_path}",
89
+ metadata_fn=lambda store_path: {"store_path": store_path},
90
+ warn_threshold_secs=5,
91
+ warn_message="Loading store '{store_path}' took longer than expected",
92
+ error_threshold_secs=10,
93
+ )
86
94
  def _load_store(store_path: str) -> Any:
87
- if "/" in store_path or ".py:" in store_path:
88
- modname = "".join(choice("abcdefghijklmnopqrstuvwxyz") for _ in range(24))
89
- path_name, function = store_path.rsplit(":", 1)
90
- module_name = path_name.rstrip(":")
91
- # Load from file path
92
- modspec = importlib.util.spec_from_file_location(modname, module_name)
93
- if modspec is None:
94
- raise ValueError(f"Could not find store file: {path_name}")
95
- module = importlib.util.module_from_spec(modspec)
96
- sys.modules[module_name] = module
97
- modspec.loader.exec_module(module) # type: ignore[possibly-unbound-attribute]
98
-
99
- else:
100
- path_name, function = store_path.rsplit(".", 1)
101
- module = importlib.import_module(path_name)
95
+ with profiled_import(store_path):
96
+ if "/" in store_path or ".py:" in store_path:
97
+ modname = "".join(choice("abcdefghijklmnopqrstuvwxyz") for _ in range(24))
98
+ path_name, function = store_path.rsplit(":", 1)
99
+ module_name = path_name.rstrip(":")
100
+ # Load from file path
101
+ modspec = importlib.util.spec_from_file_location(modname, module_name)
102
+ if modspec is None:
103
+ raise ValueError(f"Could not find store file: {path_name}")
104
+ module = importlib.util.module_from_spec(modspec)
105
+ sys.modules[module_name] = module
106
+ modspec.loader.exec_module(module) # type: ignore[possibly-unbound-attribute]
107
+
108
+ else:
109
+ path_name, function = store_path.rsplit(".", 1)
110
+ module = importlib.import_module(path_name)
102
111
 
103
112
  try:
104
113
  store: BaseStore | Callable[[config.StoreConfig], BaseStore] = module.__dict__[