langgraph-api 0.4.1__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 (135) hide show
  1. langgraph_api/__init__.py +1 -1
  2. langgraph_api/api/__init__.py +111 -51
  3. langgraph_api/api/a2a.py +1610 -0
  4. langgraph_api/api/assistants.py +212 -89
  5. langgraph_api/api/mcp.py +3 -3
  6. langgraph_api/api/meta.py +52 -28
  7. langgraph_api/api/openapi.py +27 -17
  8. langgraph_api/api/profile.py +108 -0
  9. langgraph_api/api/runs.py +342 -195
  10. langgraph_api/api/store.py +19 -2
  11. langgraph_api/api/threads.py +209 -27
  12. langgraph_api/asgi_transport.py +14 -9
  13. langgraph_api/asyncio.py +14 -4
  14. langgraph_api/auth/custom.py +52 -37
  15. langgraph_api/auth/langsmith/backend.py +4 -3
  16. langgraph_api/auth/langsmith/client.py +13 -8
  17. langgraph_api/cli.py +230 -133
  18. langgraph_api/command.py +5 -3
  19. langgraph_api/config/__init__.py +532 -0
  20. langgraph_api/config/_parse.py +58 -0
  21. langgraph_api/config/schemas.py +431 -0
  22. langgraph_api/cron_scheduler.py +17 -1
  23. langgraph_api/encryption/__init__.py +15 -0
  24. langgraph_api/encryption/aes_json.py +158 -0
  25. langgraph_api/encryption/context.py +35 -0
  26. langgraph_api/encryption/custom.py +280 -0
  27. langgraph_api/encryption/middleware.py +632 -0
  28. langgraph_api/encryption/shared.py +63 -0
  29. langgraph_api/errors.py +12 -1
  30. langgraph_api/executor_entrypoint.py +11 -6
  31. langgraph_api/feature_flags.py +29 -0
  32. langgraph_api/graph.py +176 -76
  33. langgraph_api/grpc/client.py +313 -0
  34. langgraph_api/grpc/config_conversion.py +231 -0
  35. langgraph_api/grpc/generated/__init__.py +29 -0
  36. langgraph_api/grpc/generated/checkpointer_pb2.py +63 -0
  37. langgraph_api/grpc/generated/checkpointer_pb2.pyi +99 -0
  38. langgraph_api/grpc/generated/checkpointer_pb2_grpc.py +329 -0
  39. langgraph_api/grpc/generated/core_api_pb2.py +216 -0
  40. langgraph_api/grpc/generated/core_api_pb2.pyi +905 -0
  41. langgraph_api/grpc/generated/core_api_pb2_grpc.py +1621 -0
  42. langgraph_api/grpc/generated/engine_common_pb2.py +219 -0
  43. langgraph_api/grpc/generated/engine_common_pb2.pyi +722 -0
  44. langgraph_api/grpc/generated/engine_common_pb2_grpc.py +24 -0
  45. langgraph_api/grpc/generated/enum_cancel_run_action_pb2.py +37 -0
  46. langgraph_api/grpc/generated/enum_cancel_run_action_pb2.pyi +12 -0
  47. langgraph_api/grpc/generated/enum_cancel_run_action_pb2_grpc.py +24 -0
  48. langgraph_api/grpc/generated/enum_control_signal_pb2.py +37 -0
  49. langgraph_api/grpc/generated/enum_control_signal_pb2.pyi +16 -0
  50. langgraph_api/grpc/generated/enum_control_signal_pb2_grpc.py +24 -0
  51. langgraph_api/grpc/generated/enum_durability_pb2.py +37 -0
  52. langgraph_api/grpc/generated/enum_durability_pb2.pyi +16 -0
  53. langgraph_api/grpc/generated/enum_durability_pb2_grpc.py +24 -0
  54. langgraph_api/grpc/generated/enum_multitask_strategy_pb2.py +37 -0
  55. langgraph_api/grpc/generated/enum_multitask_strategy_pb2.pyi +16 -0
  56. langgraph_api/grpc/generated/enum_multitask_strategy_pb2_grpc.py +24 -0
  57. langgraph_api/grpc/generated/enum_run_status_pb2.py +37 -0
  58. langgraph_api/grpc/generated/enum_run_status_pb2.pyi +22 -0
  59. langgraph_api/grpc/generated/enum_run_status_pb2_grpc.py +24 -0
  60. langgraph_api/grpc/generated/enum_stream_mode_pb2.py +37 -0
  61. langgraph_api/grpc/generated/enum_stream_mode_pb2.pyi +28 -0
  62. langgraph_api/grpc/generated/enum_stream_mode_pb2_grpc.py +24 -0
  63. langgraph_api/grpc/generated/enum_thread_status_pb2.py +37 -0
  64. langgraph_api/grpc/generated/enum_thread_status_pb2.pyi +16 -0
  65. langgraph_api/grpc/generated/enum_thread_status_pb2_grpc.py +24 -0
  66. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.py +37 -0
  67. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.pyi +16 -0
  68. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2_grpc.py +24 -0
  69. langgraph_api/grpc/generated/errors_pb2.py +39 -0
  70. langgraph_api/grpc/generated/errors_pb2.pyi +21 -0
  71. langgraph_api/grpc/generated/errors_pb2_grpc.py +24 -0
  72. langgraph_api/grpc/ops/__init__.py +370 -0
  73. langgraph_api/grpc/ops/assistants.py +424 -0
  74. langgraph_api/grpc/ops/runs.py +792 -0
  75. langgraph_api/grpc/ops/threads.py +1013 -0
  76. langgraph_api/http.py +16 -5
  77. langgraph_api/http_metrics.py +15 -35
  78. langgraph_api/http_metrics_utils.py +38 -0
  79. langgraph_api/js/build.mts +1 -1
  80. langgraph_api/js/client.http.mts +13 -7
  81. langgraph_api/js/client.mts +2 -5
  82. langgraph_api/js/package.json +29 -28
  83. langgraph_api/js/remote.py +56 -30
  84. langgraph_api/js/src/graph.mts +20 -0
  85. langgraph_api/js/sse.py +2 -2
  86. langgraph_api/js/ui.py +1 -1
  87. langgraph_api/js/yarn.lock +1204 -1006
  88. langgraph_api/logging.py +29 -2
  89. langgraph_api/metadata.py +99 -28
  90. langgraph_api/middleware/http_logger.py +7 -2
  91. langgraph_api/middleware/private_network.py +7 -7
  92. langgraph_api/models/run.py +54 -93
  93. langgraph_api/otel_context.py +205 -0
  94. langgraph_api/patch.py +5 -3
  95. langgraph_api/queue_entrypoint.py +154 -65
  96. langgraph_api/route.py +47 -5
  97. langgraph_api/schema.py +88 -10
  98. langgraph_api/self_hosted_logs.py +124 -0
  99. langgraph_api/self_hosted_metrics.py +450 -0
  100. langgraph_api/serde.py +79 -37
  101. langgraph_api/server.py +138 -60
  102. langgraph_api/state.py +4 -3
  103. langgraph_api/store.py +25 -16
  104. langgraph_api/stream.py +80 -29
  105. langgraph_api/thread_ttl.py +31 -13
  106. langgraph_api/timing/__init__.py +25 -0
  107. langgraph_api/timing/profiler.py +200 -0
  108. langgraph_api/timing/timer.py +318 -0
  109. langgraph_api/utils/__init__.py +53 -8
  110. langgraph_api/utils/cache.py +47 -10
  111. langgraph_api/utils/config.py +2 -1
  112. langgraph_api/utils/errors.py +77 -0
  113. langgraph_api/utils/future.py +10 -6
  114. langgraph_api/utils/headers.py +76 -2
  115. langgraph_api/utils/retriable_client.py +74 -0
  116. langgraph_api/utils/stream_codec.py +315 -0
  117. langgraph_api/utils/uuids.py +29 -62
  118. langgraph_api/validation.py +9 -0
  119. langgraph_api/webhook.py +120 -6
  120. langgraph_api/worker.py +55 -24
  121. {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/METADATA +16 -8
  122. langgraph_api-0.7.3.dist-info/RECORD +168 -0
  123. {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/WHEEL +1 -1
  124. langgraph_runtime/__init__.py +1 -0
  125. langgraph_runtime/routes.py +11 -0
  126. logging.json +1 -3
  127. openapi.json +839 -478
  128. langgraph_api/config.py +0 -387
  129. langgraph_api/js/isolate-0x130008000-46649-46649-v8.log +0 -4430
  130. langgraph_api/js/isolate-0x138008000-44681-44681-v8.log +0 -4430
  131. langgraph_api/js/package-lock.json +0 -3308
  132. langgraph_api-0.4.1.dist-info/RECORD +0 -107
  133. /langgraph_api/{utils.py → grpc/__init__.py} +0 -0
  134. {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/entry_points.txt +0 -0
  135. {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,8 @@
1
+ from functools import partial
1
2
  from typing import Any
2
3
  from uuid import uuid4
3
4
 
5
+ import jsonschema_rs
4
6
  import structlog
5
7
 
6
8
  # TODO: Remove dependency on langchain-core here.
@@ -12,18 +14,28 @@ from starlette.responses import Response
12
14
  from starlette.routing import BaseRoute
13
15
 
14
16
  from langgraph_api import store as api_store
15
- from langgraph_api.feature_flags import USE_RUNTIME_CONTEXT_API
17
+ from langgraph_api.encryption.middleware import (
18
+ decrypt_response,
19
+ decrypt_responses,
20
+ encrypt_request,
21
+ )
22
+ from langgraph_api.feature_flags import (
23
+ IS_POSTGRES_OR_GRPC_BACKEND,
24
+ USE_RUNTIME_CONTEXT_API,
25
+ )
16
26
  from langgraph_api.graph import get_assistant_id, get_graph
27
+ from langgraph_api.grpc.ops import Assistants as GrpcAssistants
17
28
  from langgraph_api.js.base import BaseRemotePregel
18
29
  from langgraph_api.route import ApiRequest, ApiResponse, ApiRoute
19
- from langgraph_api.schema import ASSISTANT_FIELDS
20
- from langgraph_api.serde import ajson_loads
30
+ from langgraph_api.schema import ASSISTANT_ENCRYPTION_FIELDS, ASSISTANT_FIELDS
31
+ from langgraph_api.serde import json_loads
21
32
  from langgraph_api.utils import (
22
33
  fetchone,
23
34
  get_pagination_headers,
24
35
  validate_select_columns,
25
36
  validate_uuid,
26
37
  )
38
+ from langgraph_api.utils.headers import get_configurable_headers
27
39
  from langgraph_api.validation import (
28
40
  AssistantCountRequest,
29
41
  AssistantCreate,
@@ -31,14 +43,22 @@ from langgraph_api.validation import (
31
43
  AssistantSearchRequest,
32
44
  AssistantVersionChange,
33
45
  AssistantVersionsSearchRequest,
46
+ ConfigValidator,
34
47
  )
35
48
  from langgraph_runtime.checkpoint import Checkpointer
36
- from langgraph_runtime.database import connect
37
- from langgraph_runtime.ops import Assistants
49
+ from langgraph_runtime.database import connect as base_connect
38
50
  from langgraph_runtime.retry import retry_db
39
51
 
40
52
  logger = structlog.stdlib.get_logger(__name__)
41
53
 
54
+ if IS_POSTGRES_OR_GRPC_BACKEND:
55
+ CrudAssistants = GrpcAssistants
56
+ else:
57
+ from langgraph_runtime.ops import Assistants
58
+
59
+ CrudAssistants = Assistants
60
+
61
+ connect = partial(base_connect, supports_core_api=IS_POSTGRES_OR_GRPC_BACKEND)
42
62
 
43
63
  EXCLUDED_CONFIG_SCHEMA = (
44
64
  "__pregel_checkpointer",
@@ -54,7 +74,7 @@ def _get_configurable_jsonschema(graph: Pregel) -> dict:
54
74
 
55
75
  Important: we only return the `configurable` part of the schema.
56
76
 
57
- The default get_config_schema method returns the entire schema (RunnableConfig),
77
+ The default get_config_schema method returns the entire schema (Config),
58
78
  which includes other root keys like "max_concurrency", which we
59
79
  do not want to expose.
60
80
 
@@ -110,21 +130,21 @@ def _graph_schemas(graph: Pregel) -> dict:
110
130
  input_schema = graph.get_input_jsonschema()
111
131
  except Exception as e:
112
132
  logger.warning(
113
- f"Failed to get input schema for graph {graph.name} with error: `{str(e)}`"
133
+ f"Failed to get input schema for graph {graph.name} with error: `{e!s}`"
114
134
  )
115
135
  input_schema = None
116
136
  try:
117
137
  output_schema = graph.get_output_jsonschema()
118
138
  except Exception as e:
119
139
  logger.warning(
120
- f"Failed to get output schema for graph {graph.name} with error: `{str(e)}`"
140
+ f"Failed to get output schema for graph {graph.name} with error: `{e!s}`"
121
141
  )
122
142
  output_schema = None
123
143
  try:
124
144
  state_schema = _state_jsonschema(graph)
125
145
  except Exception as e:
126
146
  logger.warning(
127
- f"Failed to get state schema for graph {graph.name} with error: `{str(e)}`"
147
+ f"Failed to get state schema for graph {graph.name} with error: `{e!s}`"
128
148
  )
129
149
  state_schema = None
130
150
 
@@ -132,7 +152,7 @@ def _graph_schemas(graph: Pregel) -> dict:
132
152
  config_schema = _get_configurable_jsonschema(graph)
133
153
  except Exception as e:
134
154
  logger.warning(
135
- f"Failed to get config schema for graph {graph.name} with error: `{str(e)}`"
155
+ f"Failed to get config schema for graph {graph.name} with error: `{e!s}`"
136
156
  )
137
157
  config_schema = None
138
158
 
@@ -141,7 +161,7 @@ def _graph_schemas(graph: Pregel) -> dict:
141
161
  context_schema = graph.get_context_jsonschema()
142
162
  except Exception as e:
143
163
  logger.warning(
144
- f"Failed to get context schema for graph {graph.name} with error: `{str(e)}`"
164
+ f"Failed to get context schema for graph {graph.name} with error: `{e!s}`"
145
165
  )
146
166
  context_schema = graph.config_schema() # type: ignore[deprecated]
147
167
  else:
@@ -162,19 +182,40 @@ async def create_assistant(request: ApiRequest) -> ApiResponse:
162
182
  payload = await request.json(AssistantCreate)
163
183
  if assistant_id := payload.get("assistant_id"):
164
184
  validate_uuid(assistant_id, "Invalid assistant ID: must be a UUID")
185
+ config = payload.get("config")
186
+ if config:
187
+ try:
188
+ ConfigValidator.validate(config)
189
+ except jsonschema_rs.ValidationError as e:
190
+ raise HTTPException(status_code=422, detail=str(e)) from e
191
+
192
+ encrypted_payload = await encrypt_request(
193
+ payload,
194
+ "assistant",
195
+ ASSISTANT_ENCRYPTION_FIELDS,
196
+ )
197
+
165
198
  async with connect() as conn:
166
- assistant = await Assistants.put(
199
+ assistant = await CrudAssistants.put(
167
200
  conn,
168
201
  assistant_id or str(uuid4()),
169
- config=payload.get("config") or {},
170
- context=payload.get("context") or {},
202
+ config=encrypted_payload.get("config") or {},
203
+ context=encrypted_payload.get("context"), # None if not provided
171
204
  graph_id=payload["graph_id"],
172
- metadata=payload.get("metadata") or {},
205
+ metadata=encrypted_payload.get("metadata") or {},
173
206
  if_exists=payload.get("if_exists") or "raise",
174
207
  name=payload.get("name") or "Untitled",
175
208
  description=payload.get("description"),
176
209
  )
177
- return ApiResponse(await fetchone(assistant, not_found_code=409))
210
+
211
+ # Decrypt metadata, config, and context in response
212
+ assistant_data = await fetchone(assistant, not_found_code=409)
213
+ assistant_data = await decrypt_response(
214
+ assistant_data,
215
+ "assistant",
216
+ ASSISTANT_ENCRYPTION_FIELDS,
217
+ )
218
+ return ApiResponse(assistant_data)
178
219
 
179
220
 
180
221
  @retry_db
@@ -185,10 +226,17 @@ async def search_assistants(
185
226
  payload = await request.json(AssistantSearchRequest)
186
227
  select = validate_select_columns(payload.get("select") or None, ASSISTANT_FIELDS)
187
228
  offset = int(payload.get("offset") or 0)
229
+ config = payload.get("config")
230
+ if config:
231
+ try:
232
+ ConfigValidator.validate(config)
233
+ except jsonschema_rs.ValidationError as e:
234
+ raise HTTPException(status_code=422, detail=str(e)) from e
188
235
  async with connect() as conn:
189
- assistants_iter, next_offset = await Assistants.search(
236
+ assistants_iter, next_offset = await CrudAssistants.search(
190
237
  conn,
191
238
  graph_id=payload.get("graph_id"),
239
+ name=payload.get("name"),
192
240
  metadata=payload.get("metadata"),
193
241
  limit=int(payload.get("limit") or 10),
194
242
  offset=offset,
@@ -199,7 +247,15 @@ async def search_assistants(
199
247
  assistants, response_headers = await get_pagination_headers(
200
248
  assistants_iter, next_offset, offset
201
249
  )
202
- return ApiResponse(assistants, headers=response_headers)
250
+
251
+ # Decrypt metadata, config, and context in all returned assistants
252
+ decrypted_assistants = await decrypt_responses(
253
+ assistants,
254
+ "assistant",
255
+ ASSISTANT_ENCRYPTION_FIELDS,
256
+ )
257
+
258
+ return ApiResponse(decrypted_assistants, headers=response_headers)
203
259
 
204
260
 
205
261
  @retry_db
@@ -209,9 +265,10 @@ async def count_assistants(
209
265
  """Count assistants."""
210
266
  payload = await request.json(AssistantCountRequest)
211
267
  async with connect() as conn:
212
- count = await Assistants.count(
268
+ count = await CrudAssistants.count(
213
269
  conn,
214
270
  graph_id=payload.get("graph_id"),
271
+ name=payload.get("name"),
215
272
  metadata=payload.get("metadata"),
216
273
  )
217
274
  return ApiResponse(count)
@@ -225,8 +282,16 @@ async def get_assistant(
225
282
  """Get an assistant by ID."""
226
283
  validate_uuid(assistant_id, "Invalid assistant ID: must be a UUID")
227
284
  async with connect() as conn:
228
- assistant = await Assistants.get(conn, assistant_id)
229
- return ApiResponse(await fetchone(assistant))
285
+ assistant = await CrudAssistants.get(conn, assistant_id)
286
+
287
+ # Decrypt metadata, config, and context in response
288
+ assistant_data = await fetchone(assistant)
289
+ assistant_data = await decrypt_response(
290
+ assistant_data,
291
+ "assistant",
292
+ ASSISTANT_ENCRYPTION_FIELDS,
293
+ )
294
+ return ApiResponse(assistant_data)
230
295
 
231
296
 
232
297
  @retry_db
@@ -237,14 +302,18 @@ async def get_assistant_graph(
237
302
  assistant_id = get_assistant_id(str(request.path_params["assistant_id"]))
238
303
  validate_uuid(assistant_id, "Invalid assistant ID: must be a UUID")
239
304
  async with connect() as conn:
240
- assistant_ = await Assistants.get(conn, assistant_id)
305
+ assistant_ = await CrudAssistants.get(conn, assistant_id)
241
306
  assistant = await fetchone(assistant_)
242
- config = await ajson_loads(assistant["config"])
307
+ config = json_loads(assistant["config"])
308
+ configurable = config.setdefault("configurable", {})
309
+ configurable.update(get_configurable_headers(request.headers))
310
+
243
311
  async with get_graph(
244
312
  assistant["graph_id"],
245
313
  config,
246
314
  checkpointer=Checkpointer(),
247
315
  store=(await api_store.get_store()),
316
+ is_for_execution=False,
248
317
  ) as graph:
249
318
  xray: bool | int = False
250
319
  xray_query = request.query_params.get("xray")
@@ -291,41 +360,45 @@ async def get_assistant_subgraphs(
291
360
  assistant_id = request.path_params["assistant_id"]
292
361
  validate_uuid(assistant_id, "Invalid assistant ID: must be a UUID")
293
362
  async with connect() as conn:
294
- assistant_ = await Assistants.get(conn, assistant_id)
363
+ assistant_ = await CrudAssistants.get(conn, assistant_id)
295
364
  assistant = await fetchone(assistant_)
296
- config = await ajson_loads(assistant["config"])
297
- async with get_graph(
298
- assistant["graph_id"],
299
- config,
300
- checkpointer=Checkpointer(),
301
- store=(await api_store.get_store()),
302
- ) as graph:
303
- namespace = request.path_params.get("namespace")
304
-
305
- if isinstance(graph, BaseRemotePregel):
306
- return ApiResponse(
307
- await graph.fetch_subgraphs(
365
+
366
+ config = json_loads(assistant["config"])
367
+ configurable = config.setdefault("configurable", {})
368
+ configurable.update(get_configurable_headers(request.headers))
369
+ async with get_graph(
370
+ assistant["graph_id"],
371
+ config,
372
+ checkpointer=Checkpointer(),
373
+ store=(await api_store.get_store()),
374
+ is_for_execution=False,
375
+ ) as graph:
376
+ namespace = request.path_params.get("namespace")
377
+
378
+ if isinstance(graph, BaseRemotePregel):
379
+ return ApiResponse(
380
+ await graph.fetch_subgraphs(
381
+ namespace=namespace,
382
+ recurse=request.query_params.get("recurse", "False")
383
+ in ("true", "True"),
384
+ )
385
+ )
386
+
387
+ try:
388
+ return ApiResponse(
389
+ {
390
+ ns: _graph_schemas(subgraph)
391
+ async for ns, subgraph in graph.aget_subgraphs(
308
392
  namespace=namespace,
309
393
  recurse=request.query_params.get("recurse", "False")
310
394
  in ("true", "True"),
311
395
  )
312
- )
313
-
314
- try:
315
- return ApiResponse(
316
- {
317
- ns: _graph_schemas(subgraph)
318
- async for ns, subgraph in graph.aget_subgraphs(
319
- namespace=namespace,
320
- recurse=request.query_params.get("recurse", "False")
321
- in ("true", "True"),
322
- )
323
- }
324
- )
325
- except NotImplementedError:
326
- raise HTTPException(
327
- 422, detail="The graph does not support visualization"
328
- ) from None
396
+ }
397
+ )
398
+ except NotImplementedError:
399
+ raise HTTPException(
400
+ 422, detail="The graph does not support visualization"
401
+ ) from None
329
402
 
330
403
 
331
404
  @retry_db
@@ -336,38 +409,41 @@ async def get_assistant_schemas(
336
409
  assistant_id = request.path_params["assistant_id"]
337
410
  validate_uuid(assistant_id, "Invalid assistant ID: must be a UUID")
338
411
  async with connect() as conn:
339
- assistant_ = await Assistants.get(conn, assistant_id)
340
- # TODO Implementa cache so we can de-dent and release this connection.
412
+ assistant_ = await CrudAssistants.get(conn, assistant_id)
341
413
  assistant = await fetchone(assistant_)
342
- config = await ajson_loads(assistant["config"])
343
- async with get_graph(
344
- assistant["graph_id"],
345
- config,
346
- checkpointer=Checkpointer(),
347
- store=(await api_store.get_store()),
348
- ) as graph:
349
- if isinstance(graph, BaseRemotePregel):
350
- schemas = await graph.fetch_state_schema()
351
- return ApiResponse(
352
- {
353
- "graph_id": assistant["graph_id"],
354
- "input_schema": schemas.get("input"),
355
- "output_schema": schemas.get("output"),
356
- "state_schema": schemas.get("state"),
357
- "config_schema": schemas.get("config"),
358
- "context_schema": schemas.get("context"),
359
- }
360
- )
361
-
362
- schemas = _graph_schemas(graph)
363
414
 
415
+ config = json_loads(assistant["config"])
416
+ configurable = config.setdefault("configurable", {})
417
+ configurable.update(get_configurable_headers(request.headers))
418
+ async with get_graph(
419
+ assistant["graph_id"],
420
+ config,
421
+ checkpointer=Checkpointer(),
422
+ store=(await api_store.get_store()),
423
+ is_for_execution=False,
424
+ ) as graph:
425
+ if isinstance(graph, BaseRemotePregel):
426
+ schemas = await graph.fetch_state_schema()
364
427
  return ApiResponse(
365
428
  {
366
429
  "graph_id": assistant["graph_id"],
367
- **schemas,
430
+ "input_schema": schemas.get("input"),
431
+ "output_schema": schemas.get("output"),
432
+ "state_schema": schemas.get("state"),
433
+ "config_schema": schemas.get("config"),
434
+ "context_schema": schemas.get("context"),
368
435
  }
369
436
  )
370
437
 
438
+ schemas = _graph_schemas(graph)
439
+
440
+ return ApiResponse(
441
+ {
442
+ "graph_id": assistant["graph_id"],
443
+ **schemas,
444
+ }
445
+ )
446
+
371
447
 
372
448
  @retry_db
373
449
  async def patch_assistant(
@@ -377,27 +453,58 @@ async def patch_assistant(
377
453
  assistant_id = request.path_params["assistant_id"]
378
454
  validate_uuid(assistant_id, "Invalid assistant ID: must be a UUID")
379
455
  payload = await request.json(AssistantPatch)
456
+ config = payload.get("config")
457
+ if config:
458
+ try:
459
+ ConfigValidator.validate(config)
460
+ except jsonschema_rs.ValidationError as e:
461
+ raise HTTPException(status_code=422, detail=str(e)) from e
462
+
463
+ encrypted_fields = await encrypt_request(
464
+ payload,
465
+ "assistant",
466
+ ASSISTANT_ENCRYPTION_FIELDS,
467
+ )
468
+
380
469
  async with connect() as conn:
381
- assistant = await Assistants.patch(
470
+ assistant = await CrudAssistants.patch(
382
471
  conn,
383
472
  assistant_id,
384
- config=payload.get("config"),
385
- context=payload.get("context"),
473
+ config=encrypted_fields.get("config"),
474
+ context=encrypted_fields.get("context"),
386
475
  graph_id=payload.get("graph_id"),
387
- metadata=payload.get("metadata"),
476
+ metadata=encrypted_fields.get("metadata"),
388
477
  name=payload.get("name"),
389
478
  description=payload.get("description"),
390
479
  )
391
- return ApiResponse(await fetchone(assistant))
480
+
481
+ # Decrypt metadata, config, and context in response
482
+ assistant_data = await fetchone(assistant)
483
+ assistant_data = await decrypt_response(
484
+ assistant_data,
485
+ "assistant",
486
+ ASSISTANT_ENCRYPTION_FIELDS,
487
+ )
488
+ return ApiResponse(assistant_data)
392
489
 
393
490
 
394
491
  @retry_db
395
492
  async def delete_assistant(request: ApiRequest) -> Response:
396
- """Delete an assistant by ID."""
493
+ """Delete an assistant by ID.
494
+
495
+ Query params:
496
+ delete_threads: If "true", delete all threads where
497
+ metadata.assistant_id matches this assistant.
498
+ """
397
499
  assistant_id = request.path_params["assistant_id"]
398
500
  validate_uuid(assistant_id, "Invalid assistant ID: must be a UUID")
399
- async with connect() as conn:
400
- aid = await Assistants.delete(conn, assistant_id)
501
+ delete_threads = request.query_params.get("delete_threads", "").lower() == "true"
502
+
503
+ aid = await CrudAssistants.delete(
504
+ None,
505
+ assistant_id,
506
+ delete_threads=delete_threads,
507
+ )
401
508
  await fetchone(aid)
402
509
  return Response(status_code=204)
403
510
 
@@ -409,7 +516,7 @@ async def get_assistant_versions(request: ApiRequest) -> ApiResponse:
409
516
  validate_uuid(assistant_id, "Invalid assistant ID: must be a UUID")
410
517
  payload = await request.json(AssistantVersionsSearchRequest)
411
518
  async with connect() as conn:
412
- assistants_iter = await Assistants.get_versions(
519
+ assistants_iter = await CrudAssistants.get_versions(
413
520
  conn,
414
521
  assistant_id,
415
522
  metadata=payload.get("metadata") or {},
@@ -421,7 +528,15 @@ async def get_assistant_versions(request: ApiRequest) -> ApiResponse:
421
528
  raise HTTPException(
422
529
  status_code=404, detail=f"Assistant {assistant_id} not found"
423
530
  )
424
- return ApiResponse(assistants)
531
+
532
+ # Decrypt metadata, config, and context in all assistant versions
533
+ decrypted_assistants = await decrypt_responses(
534
+ assistants,
535
+ "assistant",
536
+ ASSISTANT_ENCRYPTION_FIELDS,
537
+ )
538
+
539
+ return ApiResponse(decrypted_assistants)
425
540
 
426
541
 
427
542
  @retry_db
@@ -431,10 +546,18 @@ async def set_latest_assistant_version(request: ApiRequest) -> ApiResponse:
431
546
  payload = await request.json(AssistantVersionChange)
432
547
  validate_uuid(assistant_id, "Invalid assistant ID: must be a UUID")
433
548
  async with connect() as conn:
434
- assistant = await Assistants.set_latest(
549
+ assistant = await CrudAssistants.set_latest(
435
550
  conn, assistant_id, payload.get("version")
436
551
  )
437
- return ApiResponse(await fetchone(assistant, not_found_code=404))
552
+
553
+ # Decrypt metadata, config, and context in response
554
+ assistant_data = await fetchone(assistant, not_found_code=404)
555
+ assistant_data = await decrypt_response(
556
+ assistant_data,
557
+ "assistant",
558
+ ASSISTANT_ENCRYPTION_FIELDS,
559
+ )
560
+ return ApiResponse(assistant_data)
438
561
 
439
562
 
440
563
  assistants_routes: list[BaseRoute] = [
langgraph_api/api/mcp.py CHANGED
@@ -193,13 +193,13 @@ async def handle_post_request(request: ApiRequest) -> Response:
193
193
  # Careful ID checks as the integer 0 is a valid ID
194
194
  if id_ is not None and method:
195
195
  # JSON-RPC request
196
- return await handle_jsonrpc_request(request, cast(JsonRpcRequest, message))
196
+ return await handle_jsonrpc_request(request, cast("JsonRpcRequest", message))
197
197
  elif id_ is not None:
198
198
  # JSON-RPC response
199
- return handle_jsonrpc_response(cast(JsonRpcResponse, message))
199
+ return handle_jsonrpc_response(cast("JsonRpcResponse", message))
200
200
  elif method:
201
201
  # JSON-RPC notification
202
- return handle_jsonrpc_notification(cast(JsonRpcNotification, message))
202
+ return handle_jsonrpc_notification(cast("JsonRpcNotification", message))
203
203
  else:
204
204
  # Invalid message format
205
205
  return create_error_response(
langgraph_api/api/meta.py CHANGED
@@ -1,9 +1,10 @@
1
- from typing import cast
2
-
3
1
  import langgraph.version
2
+ import structlog
4
3
  from starlette.responses import JSONResponse, PlainTextResponse
5
4
 
6
5
  from langgraph_api import __version__, config, metadata
6
+ from langgraph_api.feature_flags import FF_USE_CORE_API
7
+ from langgraph_api.grpc.ops import Runs as GrpcRuns
7
8
  from langgraph_api.http_metrics import HTTP_METRICS_COLLECTOR
8
9
  from langgraph_api.route import ApiRequest
9
10
  from langgraph_license.validation import plus_features_enabled
@@ -11,8 +12,12 @@ from langgraph_runtime.database import connect, pool_stats
11
12
  from langgraph_runtime.metrics import get_metrics
12
13
  from langgraph_runtime.ops import Runs
13
14
 
15
+ CrudRuns = GrpcRuns if FF_USE_CORE_API else Runs
16
+
14
17
  METRICS_FORMATS = {"prometheus", "json"}
15
18
 
19
+ logger = structlog.stdlib.get_logger(__name__)
20
+
16
21
 
17
22
  async def meta_info(request: ApiRequest):
18
23
  plus = plus_features_enabled()
@@ -23,7 +28,8 @@ async def meta_info(request: ApiRequest):
23
28
  "flags": {
24
29
  "assistants": True,
25
30
  "crons": plus and config.FF_CRONS_ENABLED,
26
- "langsmith": bool(config.LANGSMITH_API_KEY) and bool(config.TRACING),
31
+ "langsmith": bool(config.LANGSMITH_CONTROL_PLANE_API_KEY)
32
+ and bool(config.TRACING),
27
33
  "langsmith_tracing_replicas": True,
28
34
  },
29
35
  "host": {
@@ -45,7 +51,7 @@ async def meta_metrics(request: ApiRequest):
45
51
 
46
52
  # collect stats
47
53
  metrics = get_metrics()
48
- worker_metrics = cast(dict[str, int], metrics["workers"])
54
+ worker_metrics = metrics["workers"]
49
55
  workers_max = worker_metrics["max"]
50
56
  workers_active = worker_metrics["active"]
51
57
  workers_available = worker_metrics["available"]
@@ -64,42 +70,60 @@ async def meta_metrics(request: ApiRequest):
64
70
  async with connect() as conn:
65
71
  resp = {
66
72
  **pg_redis_stats,
67
- "queue": await Runs.stats(conn),
73
+ "queue": await CrudRuns.stats(conn),
68
74
  **http_metrics,
69
75
  }
70
76
  if config.N_JOBS_PER_WORKER > 0:
71
77
  resp["workers"] = worker_metrics
72
78
  return JSONResponse(resp)
73
79
  elif metrics_format == "prometheus":
74
- async with connect() as conn:
75
- queue_stats = await Runs.stats(conn)
80
+ metrics = []
81
+ try:
82
+ async with connect() as conn:
83
+ queue_stats = await CrudRuns.stats(conn)
76
84
 
77
- metrics = [
78
- "# HELP lg_api_num_pending_runs The number of runs currently pending.",
79
- "# TYPE lg_api_num_pending_runs gauge",
80
- f'lg_api_num_pending_runs{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {queue_stats["n_pending"]}',
81
- "# HELP lg_api_num_running_runs The number of runs currently running.",
82
- "# TYPE lg_api_num_running_runs gauge",
83
- f'lg_api_num_running_runs{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {queue_stats["n_running"]}',
84
- ]
85
-
86
- if config.N_JOBS_PER_WORKER > 0:
87
85
  metrics.extend(
88
86
  [
89
- "# HELP lg_api_workers_max The maximum number of workers available.",
90
- "# TYPE lg_api_workers_max gauge",
91
- f'lg_api_workers_max{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {workers_max}',
92
- "# HELP lg_api_workers_active The number of currently active workers.",
93
- "# TYPE lg_api_workers_active gauge",
94
- f'lg_api_workers_active{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {workers_active}',
95
- "# HELP lg_api_workers_available The number of available (idle) workers.",
96
- "# TYPE lg_api_workers_available gauge",
97
- f'lg_api_workers_available{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {workers_available}',
87
+ "# HELP lg_api_num_pending_runs The number of runs currently pending.",
88
+ "# TYPE lg_api_num_pending_runs gauge",
89
+ f'lg_api_num_pending_runs{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {queue_stats["n_pending"]}',
90
+ "# HELP lg_api_num_running_runs The number of runs currently running.",
91
+ "# TYPE lg_api_num_running_runs gauge",
92
+ f'lg_api_num_running_runs{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {queue_stats["n_running"]}',
93
+ "# HELP lg_api_pending_runs_wait_time_max The maximum time a run has been pending, in seconds.",
94
+ "# TYPE lg_api_pending_runs_wait_time_max gauge",
95
+ f'lg_api_pending_runs_wait_time_max{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {queue_stats.get("pending_runs_wait_time_max_secs") or 0}',
96
+ "# HELP lg_api_pending_runs_wait_time_med The median pending wait time across runs, in seconds.",
97
+ "# TYPE lg_api_pending_runs_wait_time_med gauge",
98
+ f'lg_api_pending_runs_wait_time_med{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {queue_stats.get("pending_runs_wait_time_med_secs") or 0}',
99
+ "# HELP lg_api_pending_unblocked_runs_wait_time_max The maximum time a run has been pending excluding runs blocked by another run on the same thread, in seconds.",
100
+ "# TYPE lg_api_pending_unblocked_runs_wait_time_max gauge",
101
+ f'lg_api_pending_unblocked_runs_wait_time_max{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {queue_stats.get("pending_unblocked_runs_wait_time_max_secs") or 0}',
98
102
  ]
99
103
  )
104
+ except Exception as e:
105
+ # if we get a db connection error/timeout, just skip queue stats
106
+ await logger.awarning(
107
+ "Ignoring error while getting run stats for /metrics", exc_info=e
108
+ )
109
+
110
+ if config.N_JOBS_PER_WORKER > 0:
111
+ metrics.extend(
112
+ [
113
+ "# HELP lg_api_workers_max The maximum number of workers available.",
114
+ "# TYPE lg_api_workers_max gauge",
115
+ f'lg_api_workers_max{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {workers_max}',
116
+ "# HELP lg_api_workers_active The number of currently active workers.",
117
+ "# TYPE lg_api_workers_active gauge",
118
+ f'lg_api_workers_active{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {workers_active}',
119
+ "# HELP lg_api_workers_available The number of available (idle) workers.",
120
+ "# TYPE lg_api_workers_available gauge",
121
+ f'lg_api_workers_available{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {workers_available}',
122
+ ]
123
+ )
100
124
 
101
- metrics.extend(http_metrics)
102
- metrics.extend(pg_redis_stats)
125
+ metrics.extend(http_metrics)
126
+ metrics.extend(pg_redis_stats)
103
127
 
104
128
  metrics_response = "\n".join(metrics)
105
129
  return PlainTextResponse(metrics_response)