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.
- langgraph_api/__init__.py +1 -1
- langgraph_api/api/__init__.py +93 -27
- langgraph_api/api/a2a.py +36 -32
- langgraph_api/api/assistants.py +114 -26
- langgraph_api/api/mcp.py +3 -3
- langgraph_api/api/meta.py +15 -2
- langgraph_api/api/openapi.py +27 -17
- langgraph_api/api/profile.py +108 -0
- langgraph_api/api/runs.py +114 -57
- langgraph_api/api/store.py +19 -2
- langgraph_api/api/threads.py +133 -10
- langgraph_api/asgi_transport.py +14 -9
- langgraph_api/auth/custom.py +23 -13
- langgraph_api/cli.py +86 -41
- langgraph_api/command.py +2 -2
- langgraph_api/config/__init__.py +532 -0
- langgraph_api/config/_parse.py +58 -0
- langgraph_api/config/schemas.py +431 -0
- langgraph_api/cron_scheduler.py +17 -1
- langgraph_api/encryption/__init__.py +15 -0
- langgraph_api/encryption/aes_json.py +158 -0
- langgraph_api/encryption/context.py +35 -0
- langgraph_api/encryption/custom.py +280 -0
- langgraph_api/encryption/middleware.py +632 -0
- langgraph_api/encryption/shared.py +63 -0
- langgraph_api/errors.py +12 -1
- langgraph_api/executor_entrypoint.py +11 -6
- langgraph_api/feature_flags.py +19 -0
- langgraph_api/graph.py +163 -64
- langgraph_api/{grpc_ops → grpc}/client.py +142 -12
- langgraph_api/{grpc_ops → grpc}/config_conversion.py +16 -10
- langgraph_api/grpc/generated/__init__.py +29 -0
- langgraph_api/grpc/generated/checkpointer_pb2.py +63 -0
- langgraph_api/grpc/generated/checkpointer_pb2.pyi +99 -0
- langgraph_api/grpc/generated/checkpointer_pb2_grpc.py +329 -0
- langgraph_api/grpc/generated/core_api_pb2.py +216 -0
- langgraph_api/{grpc_ops → grpc}/generated/core_api_pb2.pyi +292 -372
- langgraph_api/{grpc_ops → grpc}/generated/core_api_pb2_grpc.py +252 -31
- langgraph_api/grpc/generated/engine_common_pb2.py +219 -0
- langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2.pyi +178 -104
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2.pyi +12 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_durability_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_durability_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_durability_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_run_status_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_run_status_pb2.pyi +22 -0
- langgraph_api/grpc/generated/enum_run_status_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2.pyi +28 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/errors_pb2.py +39 -0
- langgraph_api/grpc/generated/errors_pb2.pyi +21 -0
- langgraph_api/grpc/generated/errors_pb2_grpc.py +24 -0
- langgraph_api/grpc/ops/__init__.py +370 -0
- langgraph_api/grpc/ops/assistants.py +424 -0
- langgraph_api/grpc/ops/runs.py +792 -0
- langgraph_api/grpc/ops/threads.py +1013 -0
- langgraph_api/http.py +16 -5
- langgraph_api/js/client.mts +1 -4
- langgraph_api/js/package.json +28 -27
- langgraph_api/js/remote.py +39 -17
- langgraph_api/js/sse.py +2 -2
- langgraph_api/js/ui.py +1 -1
- langgraph_api/js/yarn.lock +1139 -869
- langgraph_api/metadata.py +29 -3
- langgraph_api/middleware/http_logger.py +1 -1
- langgraph_api/middleware/private_network.py +7 -7
- langgraph_api/models/run.py +44 -26
- langgraph_api/otel_context.py +205 -0
- langgraph_api/patch.py +2 -2
- langgraph_api/queue_entrypoint.py +34 -35
- langgraph_api/route.py +33 -1
- langgraph_api/schema.py +84 -9
- langgraph_api/self_hosted_logs.py +2 -2
- langgraph_api/self_hosted_metrics.py +73 -3
- langgraph_api/serde.py +16 -4
- langgraph_api/server.py +33 -31
- langgraph_api/state.py +3 -2
- langgraph_api/store.py +25 -16
- langgraph_api/stream.py +20 -16
- langgraph_api/thread_ttl.py +28 -13
- langgraph_api/timing/__init__.py +25 -0
- langgraph_api/timing/profiler.py +200 -0
- langgraph_api/timing/timer.py +318 -0
- langgraph_api/utils/__init__.py +53 -8
- langgraph_api/utils/config.py +2 -1
- langgraph_api/utils/future.py +10 -6
- langgraph_api/utils/uuids.py +29 -62
- langgraph_api/validation.py +6 -0
- langgraph_api/webhook.py +120 -6
- langgraph_api/worker.py +54 -24
- {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/METADATA +8 -6
- langgraph_api-0.7.3.dist-info/RECORD +168 -0
- {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/WHEEL +1 -1
- langgraph_runtime/__init__.py +1 -0
- langgraph_runtime/routes.py +11 -0
- logging.json +1 -3
- openapi.json +635 -537
- langgraph_api/config.py +0 -523
- langgraph_api/grpc_ops/generated/__init__.py +0 -5
- langgraph_api/grpc_ops/generated/core_api_pb2.py +0 -275
- langgraph_api/grpc_ops/generated/engine_common_pb2.py +0 -194
- langgraph_api/grpc_ops/ops.py +0 -1045
- langgraph_api-0.5.4.dist-info/RECORD +0 -121
- /langgraph_api/{grpc_ops → grpc}/__init__.py +0 -0
- /langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2_grpc.py +0 -0
- {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/entry_points.txt +0 -0
- {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.
|
|
1
|
+
__version__ = "0.7.3"
|
langgraph_api/api/__init__.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 "
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
1056
|
-
|
|
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')}: {
|
|
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: {
|
|
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: {
|
|
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}: {
|
|
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: {
|
|
1554
|
+
"message": f"Internal server error: {e!s}",
|
|
1551
1555
|
},
|
|
1552
1556
|
},
|
|
1553
1557
|
)
|
langgraph_api/api/assistants.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
54
|
+
if IS_POSTGRES_OR_GRPC_BACKEND:
|
|
55
|
+
CrudAssistants = GrpcAssistants
|
|
56
|
+
else:
|
|
57
|
+
from langgraph_runtime.ops import Assistants
|
|
48
58
|
|
|
49
|
-
|
|
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: `{
|
|
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: `{
|
|
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: `{
|
|
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: `{
|
|
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: `{
|
|
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=
|
|
184
|
-
context=
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
420
|
-
context=
|
|
473
|
+
config=encrypted_fields.get("config"),
|
|
474
|
+
context=encrypted_fields.get("context"),
|
|
421
475
|
graph_id=payload.get("graph_id"),
|
|
422
|
-
metadata=
|
|
476
|
+
metadata=encrypted_fields.get("metadata"),
|
|
423
477
|
name=payload.get("name"),
|
|
424
478
|
description=payload.get("description"),
|
|
425
479
|
)
|
|
426
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] = [
|