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/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.5.4"
1
+ __version__ = "0.7.3"
@@ -6,41 +6,81 @@ import os
6
6
 
7
7
  import structlog
8
8
  from starlette.applications import Starlette
9
+ from starlette.exceptions import HTTPException
10
+ from starlette.middleware import Middleware
9
11
  from starlette.requests import Request
10
12
  from starlette.responses import HTMLResponse, JSONResponse, Response
11
13
  from starlette.routing import BaseRoute, Route
12
14
 
15
+ from langgraph_api import timing
13
16
  from langgraph_api.api.a2a import a2a_routes
14
17
  from langgraph_api.api.assistants import assistants_routes
15
18
  from langgraph_api.api.mcp import mcp_routes
16
19
  from langgraph_api.api.meta import meta_info, meta_metrics
17
20
  from langgraph_api.api.openapi import get_openapi_spec
21
+ from langgraph_api.api.profile import profile_routes
18
22
  from langgraph_api.api.runs import runs_routes
19
23
  from langgraph_api.api.store import store_routes
20
24
  from langgraph_api.api.threads import threads_routes
21
25
  from langgraph_api.api.ui import ui_routes
22
26
  from langgraph_api.auth.middleware import auth_middleware
23
- from langgraph_api.config import HTTP_CONFIG, MIGRATIONS_PATH, MOUNT_PREFIX
27
+ from langgraph_api.config import (
28
+ FF_PYSPY_PROFILING_ENABLED,
29
+ HTTP_CONFIG,
30
+ LANGGRAPH_ENCRYPTION,
31
+ MIGRATIONS_PATH,
32
+ MOUNT_PREFIX,
33
+ )
34
+ from langgraph_api.feature_flags import IS_POSTGRES_OR_GRPC_BACKEND
24
35
  from langgraph_api.graph import js_bg_tasks
36
+ from langgraph_api.grpc.client import get_shared_client
25
37
  from langgraph_api.js.base import is_js_path
38
+ from langgraph_api.timing import profiled_import
26
39
  from langgraph_api.validation import DOCS_HTML
27
- from langgraph_runtime.database import connect, healthcheck
40
+ from langgraph_runtime.database import healthcheck
28
41
 
29
42
  logger = structlog.stdlib.get_logger(__name__)
30
43
 
31
44
 
45
+ async def grpc_healthcheck():
46
+ """Check the health of the gRPC server."""
47
+ try:
48
+ client = await get_shared_client()
49
+ await client.healthcheck()
50
+ except Exception as exc:
51
+ logger.warning(
52
+ "gRPC health check failed. Either the gRPC server is not running or is not responding.",
53
+ error=exc,
54
+ )
55
+ raise HTTPException(
56
+ status_code=500,
57
+ detail="gRPC health check failed. Either the gRPC server is not running or is not responding.",
58
+ ) from exc
59
+
60
+
32
61
  async def ok(request: Request, *, disabled: bool = False):
33
62
  if disabled:
34
63
  # We still expose an /ok endpoint even if disable_meta is set so that
35
64
  # the operator knows the server started up.
36
65
  return JSONResponse({"ok": True})
37
66
  check_db = int(request.query_params.get("check_db", "0")) # must be "0" or "1"
67
+
68
+ healthcheck_coroutines = []
69
+
38
70
  if check_db:
39
- await healthcheck()
71
+ healthcheck_coroutines.append(healthcheck())
72
+
40
73
  if js_bg_tasks:
41
74
  from langgraph_api.js.remote import js_healthcheck
42
75
 
43
- await js_healthcheck()
76
+ healthcheck_coroutines.append(js_healthcheck())
77
+
78
+ # Check `core-api` server health
79
+ if IS_POSTGRES_OR_GRPC_BACKEND:
80
+ healthcheck_coroutines.append(grpc_healthcheck())
81
+
82
+ await asyncio.gather(*healthcheck_coroutines)
83
+
44
84
  return JSONResponse({"ok": True})
45
85
 
46
86
 
@@ -54,6 +94,7 @@ async def docs(request: Request):
54
94
 
55
95
 
56
96
  shadowable_meta_routes: list[BaseRoute] = [
97
+ Route("/", ok, methods=["GET"]), # Root health check for load balancers
57
98
  Route("/info", meta_info, methods=["GET"]),
58
99
  ]
59
100
  unshadowable_meta_routes: list[BaseRoute] = [
@@ -64,6 +105,13 @@ unshadowable_meta_routes: list[BaseRoute] = [
64
105
  ]
65
106
 
66
107
  middleware_for_protected_routes = [auth_middleware]
108
+
109
+ # Add encryption context middleware if encryption is configured
110
+ if LANGGRAPH_ENCRYPTION:
111
+ from langgraph_api.encryption.middleware import EncryptionContextMiddleware
112
+
113
+ middleware_for_protected_routes.append(Middleware(EncryptionContextMiddleware))
114
+
67
115
  protected_routes: list[BaseRoute] = []
68
116
 
69
117
  if HTTP_CONFIG:
@@ -75,6 +123,8 @@ if HTTP_CONFIG:
75
123
  protected_routes.extend(threads_routes)
76
124
  if not HTTP_CONFIG.get("disable_store"):
77
125
  protected_routes.extend(store_routes)
126
+ if FF_PYSPY_PROFILING_ENABLED:
127
+ protected_routes.extend(profile_routes)
78
128
  if not HTTP_CONFIG.get("disable_ui"):
79
129
  protected_routes.extend(ui_routes)
80
130
  if not HTTP_CONFIG.get("disable_mcp"):
@@ -86,14 +136,30 @@ else:
86
136
  protected_routes.extend(runs_routes)
87
137
  protected_routes.extend(threads_routes)
88
138
  protected_routes.extend(store_routes)
139
+ if FF_PYSPY_PROFILING_ENABLED:
140
+ protected_routes.extend(profile_routes)
89
141
  protected_routes.extend(ui_routes)
90
142
  protected_routes.extend(mcp_routes)
91
143
  protected_routes.extend(a2a_routes)
92
144
 
93
145
 
146
+ def _metadata_fn(app_import: str) -> dict[str, str]:
147
+ return {"app": app_import}
148
+
149
+
150
+ @timing.timer(
151
+ message="Loaded custom app from {app}",
152
+ metadata_fn=_metadata_fn,
153
+ warn_threshold_secs=3,
154
+ warn_message=(
155
+ "Import for custom app {app} exceeded the expected startup time. "
156
+ "Slow initialization (often due to work executed at import time) can delay readiness, "
157
+ "reduce scale-out capacity, and may cause deployments to be marked unhealthy."
158
+ ),
159
+ error_threshold_secs=30,
160
+ )
94
161
  def load_custom_app(app_import: str) -> Starlette | None:
95
162
  # Expect a string in either "path/to/file.py:my_variable" or "some.module.in:my_variable"
96
- logger.info(f"Loading custom app from {app_import}")
97
163
  path, name = app_import.rsplit(":", 1)
98
164
 
99
165
  # skip loading custom app if it's a js path
@@ -103,16 +169,19 @@ def load_custom_app(app_import: str) -> Starlette | None:
103
169
 
104
170
  try:
105
171
  os.environ["__LANGGRAPH_DEFER_LOOPBACK_TRANSPORT"] = "true"
106
- if os.path.isfile(path) or path.endswith(".py"):
107
- # Import from file path using a unique module name.
108
- spec = importlib.util.spec_from_file_location("user_router_module", path)
109
- if spec is None or spec.loader is None:
110
- raise ImportError(f"Cannot load spec from {path}")
111
- module = importlib.util.module_from_spec(spec)
112
- spec.loader.exec_module(module)
113
- else:
114
- # Import as a normal module.
115
- module = importlib.import_module(path)
172
+ with profiled_import(app_import):
173
+ if os.path.isfile(path) or path.endswith(".py"):
174
+ # Import from file path using a unique module name.
175
+ spec = importlib.util.spec_from_file_location(
176
+ "user_router_module", path
177
+ )
178
+ if spec is None or spec.loader is None:
179
+ raise ImportError(f"Cannot load spec from {path}")
180
+ module = importlib.util.module_from_spec(spec)
181
+ spec.loader.exec_module(module)
182
+ else:
183
+ # Import as a normal module.
184
+ module = importlib.import_module(path)
116
185
  user_router = getattr(module, name)
117
186
  if not isinstance(user_router, Starlette):
118
187
  raise TypeError(
@@ -142,18 +211,15 @@ if HTTP_CONFIG:
142
211
 
143
212
  if router_import := HTTP_CONFIG.get("app"):
144
213
  user_router = load_custom_app(router_import)
214
+ if user_router:
215
+ user_router.router.lifespan_context = timing.wrap_lifespan_context_aenter(
216
+ user_router.router.lifespan_context,
217
+ )
145
218
 
146
219
 
147
- if "inmem" in MIGRATIONS_PATH:
148
-
149
- async def truncate(request: Request):
150
- from langgraph_runtime.checkpoint import Checkpointer
151
-
152
- await asyncio.to_thread(Checkpointer().clear)
153
- async with connect() as conn:
154
- await asyncio.to_thread(conn.clear)
155
- return JSONResponse({"ok": True})
220
+ if "__inmem" in MIGRATIONS_PATH:
221
+ from langgraph_runtime_inmem.routes import get_internal_routes
156
222
 
157
- unshadowable_meta_routes.insert(
158
- 0, Route("/internal/truncate", truncate, methods=["POST"])
159
- )
223
+ if get_internal_routes is not None:
224
+ for route in get_internal_routes():
225
+ unshadowable_meta_routes.insert(0, route)
langgraph_api/api/a2a.py CHANGED
@@ -10,6 +10,7 @@ The implementation currently supports JSON-RPC 2.0 transport only.
10
10
  Push notifications are not implemented.
11
11
  """
12
12
 
13
+ import asyncio
13
14
  import functools
14
15
  import uuid
15
16
  from datetime import UTC, datetime
@@ -276,9 +277,7 @@ def _extract_a2a_response(result: dict[str, Any]) -> str:
276
277
  isinstance(message, dict)
277
278
  and message.get("role") == "assistant"
278
279
  and "content" in message
279
- or message.get("type") == "ai"
280
- and "content" in message
281
- ):
280
+ ) or (message.get("type") == "ai" and "content" in message):
282
281
  return message["content"]
283
282
 
284
283
  # If no assistant message found, return the last message content
@@ -446,7 +445,7 @@ def _map_runs_create_error_to_rpc(
446
445
  return {
447
446
  "error": {
448
447
  "code": ERROR_CODE_INTERNAL_ERROR,
449
- "message": f"Internal server error: {str(exception)}",
448
+ "message": f"Internal server error: {exception!s}",
450
449
  }
451
450
  }
452
451
 
@@ -502,7 +501,7 @@ def _map_runs_get_error_to_rpc(
502
501
  return {
503
502
  "error": {
504
503
  "code": ERROR_CODE_INTERNAL_ERROR,
505
- "message": f"Internal server error: {str(exception)}",
504
+ "message": f"Internal server error: {exception!s}",
506
505
  }
507
506
  }
508
507
 
@@ -687,14 +686,14 @@ async def handle_post_request(request: ApiRequest, assistant_id: str) -> Respons
687
686
  if id_ is not None and method:
688
687
  # JSON-RPC request
689
688
  return await handle_jsonrpc_request(
690
- request, cast(JsonRpcRequest, message), assistant_id
689
+ request, cast("JsonRpcRequest", message), assistant_id
691
690
  )
692
691
  elif id_ is not None:
693
692
  # JSON-RPC response (not expected in A2A server context)
694
693
  return handle_jsonrpc_response()
695
694
  elif method:
696
695
  # JSON-RPC notification
697
- return handle_jsonrpc_notification(cast(JsonRpcNotification, message))
696
+ return handle_jsonrpc_notification(cast("JsonRpcNotification", message))
698
697
  else:
699
698
  return create_error_response(
700
699
  "Invalid message format. Message must be a JSON-RPC request, "
@@ -926,7 +925,7 @@ async def handle_message_send(
926
925
  return {
927
926
  "error": {
928
927
  "code": ERROR_CODE_INTERNAL_ERROR,
929
- "message": f"Internal server error: {str(e)}",
928
+ "message": f"Internal server error: {e!s}",
930
929
  }
931
930
  }
932
931
 
@@ -1020,10 +1019,17 @@ async def handle_tasks_get(
1020
1019
  }
1021
1020
 
1022
1021
  try:
1023
- run_info = await client.runs.get(
1024
- thread_id=context_id,
1025
- run_id=task_id,
1026
- headers=request.headers,
1022
+ # TODO: fix the N+1 query issue
1023
+ run_info, thread_info = await asyncio.gather(
1024
+ client.runs.get(
1025
+ thread_id=context_id,
1026
+ run_id=task_id,
1027
+ headers=request.headers,
1028
+ ),
1029
+ client.threads.get(
1030
+ thread_id=context_id,
1031
+ headers=request.headers,
1032
+ ),
1027
1033
  )
1028
1034
  except Exception as e:
1029
1035
  error_response = _map_runs_get_error_to_rpc(e, task_id, context_id)
@@ -1032,19 +1038,6 @@ async def handle_tasks_get(
1032
1038
  raise
1033
1039
  return error_response
1034
1040
 
1035
- assistant_id = run_info.get("assistant_id")
1036
- if assistant_id:
1037
- try:
1038
- # Verify that the assistant exists
1039
- await _get_assistant(assistant_id, request.headers)
1040
- except ValueError as e:
1041
- return {
1042
- "error": {
1043
- "code": ERROR_CODE_INVALID_PARAMS,
1044
- "message": str(e),
1045
- }
1046
- }
1047
-
1048
1041
  lg_status = run_info.get("status", "unknown")
1049
1042
 
1050
1043
  if lg_status == "pending":
@@ -1052,8 +1045,18 @@ async def handle_tasks_get(
1052
1045
  elif lg_status == "running":
1053
1046
  a2a_state = "working"
1054
1047
  elif lg_status == "success":
1055
- a2a_state = "completed"
1056
- elif lg_status in ["error", "timeout", "interrupted"]:
1048
+ # Hack hack: if the thread **at present** is interrupted, assume
1049
+ # the run also is interrupted
1050
+ if thread_info.get("status") == "interrupted":
1051
+ a2a_state = "input-required"
1052
+ else:
1053
+ # Inspect whether there are next tasks
1054
+ a2a_state = "completed"
1055
+ elif (
1056
+ lg_status == "interrupted"
1057
+ ): # Note that this is if you interrupt FROM the outside (i.e., with double texting)
1058
+ a2a_state = "input-required"
1059
+ elif lg_status in ["error", "timeout"]:
1057
1060
  a2a_state = "failed"
1058
1061
  else:
1059
1062
  a2a_state = "submitted"
@@ -1103,12 +1106,12 @@ async def handle_tasks_get(
1103
1106
 
1104
1107
  except Exception as e:
1105
1108
  await logger.aerror(
1106
- f"Error in tasks/get for task {params.get('id')}: {str(e)}", exc_info=True
1109
+ f"Error in tasks/get for task {params.get('id')}: {e!s}", exc_info=True
1107
1110
  )
1108
1111
  return {
1109
1112
  "error": {
1110
1113
  "code": ERROR_CODE_INTERNAL_ERROR,
1111
- "message": f"Internal server error: {str(e)}",
1114
+ "message": f"Internal server error: {e!s}",
1112
1115
  }
1113
1116
  }
1114
1117
 
@@ -1149,6 +1152,7 @@ async def handle_tasks_cancel(
1149
1152
  # ============================================================================
1150
1153
 
1151
1154
 
1155
+ # TODO: add routes for /a2a/agents/{id}/card
1152
1156
  async def generate_agent_card(request: ApiRequest, assistant_id: str) -> dict[str, Any]:
1153
1157
  """Generate A2A Agent Card for a specific assistant.
1154
1158
 
@@ -1279,7 +1283,7 @@ async def handle_agent_card_endpoint(request: ApiRequest) -> Response:
1279
1283
  error_response = {
1280
1284
  "error": {
1281
1285
  "code": ERROR_CODE_INTERNAL_ERROR,
1282
- "message": f"Internal server error: {str(e)}",
1286
+ "message": f"Internal server error: {e!s}",
1283
1287
  }
1284
1288
  }
1285
1289
  return Response(
@@ -1537,7 +1541,7 @@ async def handle_message_stream(
1537
1541
  yield (b"message", {"jsonrpc": "2.0", "id": rpc_id, "result": fallback})
1538
1542
  except Exception as e:
1539
1543
  await logger.aerror(
1540
- f"Error in message/stream for assistant {assistant_id}: {str(e)}",
1544
+ f"Error in message/stream for assistant {assistant_id}: {e!s}",
1541
1545
  exc_info=True,
1542
1546
  )
1543
1547
  yield (
@@ -1547,7 +1551,7 @@ async def handle_message_stream(
1547
1551
  "id": rpc_id,
1548
1552
  "error": {
1549
1553
  "code": ERROR_CODE_INTERNAL_ERROR,
1550
- "message": f"Internal server error: {str(e)}",
1554
+ "message": f"Internal server error: {e!s}",
1551
1555
  },
1552
1556
  },
1553
1557
  )
@@ -14,12 +14,20 @@ from starlette.responses import Response
14
14
  from starlette.routing import BaseRoute
15
15
 
16
16
  from langgraph_api import store as api_store
17
- from langgraph_api.feature_flags import FF_USE_CORE_API, 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
+ )
18
26
  from langgraph_api.graph import get_assistant_id, get_graph
19
- from langgraph_api.grpc_ops.ops import Assistants as GrpcAssistants
27
+ from langgraph_api.grpc.ops import Assistants as GrpcAssistants
20
28
  from langgraph_api.js.base import BaseRemotePregel
21
29
  from langgraph_api.route import ApiRequest, ApiResponse, ApiRoute
22
- from langgraph_api.schema import ASSISTANT_FIELDS
30
+ from langgraph_api.schema import ASSISTANT_ENCRYPTION_FIELDS, ASSISTANT_FIELDS
23
31
  from langgraph_api.serde import json_loads
24
32
  from langgraph_api.utils import (
25
33
  fetchone,
@@ -39,14 +47,18 @@ from langgraph_api.validation import (
39
47
  )
40
48
  from langgraph_runtime.checkpoint import Checkpointer
41
49
  from langgraph_runtime.database import connect as base_connect
42
- from langgraph_runtime.ops import Assistants
43
50
  from langgraph_runtime.retry import retry_db
44
51
 
45
52
  logger = structlog.stdlib.get_logger(__name__)
46
53
 
47
- CrudAssistants = GrpcAssistants if FF_USE_CORE_API else Assistants
54
+ if IS_POSTGRES_OR_GRPC_BACKEND:
55
+ CrudAssistants = GrpcAssistants
56
+ else:
57
+ from langgraph_runtime.ops import Assistants
48
58
 
49
- connect = partial(base_connect, supports_core_api=FF_USE_CORE_API)
59
+ CrudAssistants = Assistants
60
+
61
+ connect = partial(base_connect, supports_core_api=IS_POSTGRES_OR_GRPC_BACKEND)
50
62
 
51
63
  EXCLUDED_CONFIG_SCHEMA = (
52
64
  "__pregel_checkpointer",
@@ -118,21 +130,21 @@ def _graph_schemas(graph: Pregel) -> dict:
118
130
  input_schema = graph.get_input_jsonschema()
119
131
  except Exception as e:
120
132
  logger.warning(
121
- 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}`"
122
134
  )
123
135
  input_schema = None
124
136
  try:
125
137
  output_schema = graph.get_output_jsonschema()
126
138
  except Exception as e:
127
139
  logger.warning(
128
- 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}`"
129
141
  )
130
142
  output_schema = None
131
143
  try:
132
144
  state_schema = _state_jsonschema(graph)
133
145
  except Exception as e:
134
146
  logger.warning(
135
- 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}`"
136
148
  )
137
149
  state_schema = None
138
150
 
@@ -140,7 +152,7 @@ def _graph_schemas(graph: Pregel) -> dict:
140
152
  config_schema = _get_configurable_jsonschema(graph)
141
153
  except Exception as e:
142
154
  logger.warning(
143
- 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}`"
144
156
  )
145
157
  config_schema = None
146
158
 
@@ -149,7 +161,7 @@ def _graph_schemas(graph: Pregel) -> dict:
149
161
  context_schema = graph.get_context_jsonschema()
150
162
  except Exception as e:
151
163
  logger.warning(
152
- 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}`"
153
165
  )
154
166
  context_schema = graph.config_schema() # type: ignore[deprecated]
155
167
  else:
@@ -176,20 +188,34 @@ async def create_assistant(request: ApiRequest) -> ApiResponse:
176
188
  ConfigValidator.validate(config)
177
189
  except jsonschema_rs.ValidationError as e:
178
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
+
179
198
  async with connect() as conn:
180
199
  assistant = await CrudAssistants.put(
181
200
  conn,
182
201
  assistant_id or str(uuid4()),
183
- config=payload.get("config") or {},
184
- context=payload.get("context") or {},
202
+ config=encrypted_payload.get("config") or {},
203
+ context=encrypted_payload.get("context"), # None if not provided
185
204
  graph_id=payload["graph_id"],
186
- metadata=payload.get("metadata") or {},
205
+ metadata=encrypted_payload.get("metadata") or {},
187
206
  if_exists=payload.get("if_exists") or "raise",
188
207
  name=payload.get("name") or "Untitled",
189
208
  description=payload.get("description"),
190
209
  )
191
210
 
192
- return ApiResponse(await fetchone(assistant, not_found_code=409))
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)
193
219
 
194
220
 
195
221
  @retry_db
@@ -210,6 +236,7 @@ async def search_assistants(
210
236
  assistants_iter, next_offset = await CrudAssistants.search(
211
237
  conn,
212
238
  graph_id=payload.get("graph_id"),
239
+ name=payload.get("name"),
213
240
  metadata=payload.get("metadata"),
214
241
  limit=int(payload.get("limit") or 10),
215
242
  offset=offset,
@@ -220,7 +247,15 @@ async def search_assistants(
220
247
  assistants, response_headers = await get_pagination_headers(
221
248
  assistants_iter, next_offset, offset
222
249
  )
223
- 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)
224
259
 
225
260
 
226
261
  @retry_db
@@ -233,6 +268,7 @@ async def count_assistants(
233
268
  count = await CrudAssistants.count(
234
269
  conn,
235
270
  graph_id=payload.get("graph_id"),
271
+ name=payload.get("name"),
236
272
  metadata=payload.get("metadata"),
237
273
  )
238
274
  return ApiResponse(count)
@@ -247,7 +283,15 @@ async def get_assistant(
247
283
  validate_uuid(assistant_id, "Invalid assistant ID: must be a UUID")
248
284
  async with connect() as conn:
249
285
  assistant = await CrudAssistants.get(conn, assistant_id)
250
- return ApiResponse(await fetchone(assistant))
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)
251
295
 
252
296
 
253
297
  @retry_db
@@ -269,6 +313,7 @@ async def get_assistant_graph(
269
313
  config,
270
314
  checkpointer=Checkpointer(),
271
315
  store=(await api_store.get_store()),
316
+ is_for_execution=False,
272
317
  ) as graph:
273
318
  xray: bool | int = False
274
319
  xray_query = request.query_params.get("xray")
@@ -326,6 +371,7 @@ async def get_assistant_subgraphs(
326
371
  config,
327
372
  checkpointer=Checkpointer(),
328
373
  store=(await api_store.get_store()),
374
+ is_for_execution=False,
329
375
  ) as graph:
330
376
  namespace = request.path_params.get("namespace")
331
377
 
@@ -374,6 +420,7 @@ async def get_assistant_schemas(
374
420
  config,
375
421
  checkpointer=Checkpointer(),
376
422
  store=(await api_store.get_store()),
423
+ is_for_execution=False,
377
424
  ) as graph:
378
425
  if isinstance(graph, BaseRemotePregel):
379
426
  schemas = await graph.fetch_state_schema()
@@ -412,27 +459,52 @@ async def patch_assistant(
412
459
  ConfigValidator.validate(config)
413
460
  except jsonschema_rs.ValidationError as e:
414
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
+
415
469
  async with connect() as conn:
416
470
  assistant = await CrudAssistants.patch(
417
471
  conn,
418
472
  assistant_id,
419
- config=payload.get("config"),
420
- context=payload.get("context"),
473
+ config=encrypted_fields.get("config"),
474
+ context=encrypted_fields.get("context"),
421
475
  graph_id=payload.get("graph_id"),
422
- metadata=payload.get("metadata"),
476
+ metadata=encrypted_fields.get("metadata"),
423
477
  name=payload.get("name"),
424
478
  description=payload.get("description"),
425
479
  )
426
- 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)
427
489
 
428
490
 
429
491
  @retry_db
430
492
  async def delete_assistant(request: ApiRequest) -> Response:
431
- """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
+ """
432
499
  assistant_id = request.path_params["assistant_id"]
433
500
  validate_uuid(assistant_id, "Invalid assistant ID: must be a UUID")
434
- async with connect() as conn:
435
- aid = await CrudAssistants.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
+ )
436
508
  await fetchone(aid)
437
509
  return Response(status_code=204)
438
510
 
@@ -456,7 +528,15 @@ async def get_assistant_versions(request: ApiRequest) -> ApiResponse:
456
528
  raise HTTPException(
457
529
  status_code=404, detail=f"Assistant {assistant_id} not found"
458
530
  )
459
- 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)
460
540
 
461
541
 
462
542
  @retry_db
@@ -469,7 +549,15 @@ async def set_latest_assistant_version(request: ApiRequest) -> ApiResponse:
469
549
  assistant = await CrudAssistants.set_latest(
470
550
  conn, assistant_id, payload.get("version")
471
551
  )
472
- 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)
473
561
 
474
562
 
475
563
  assistants_routes: list[BaseRoute] = [