langgraph-api 0.16.0.dev7__py3-none-any.whl → 0.16.0.dev9__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 +53 -8
- langgraph_api/api/a2a.py +8 -11
- langgraph_api/api/runs.py +73 -43
- langgraph_api/api/store.py +40 -14
- langgraph_api/grpc/ops/threads.py +2 -0
- langgraph_api/js/client.mts +8 -1
- langgraph_api/js/remote.py +95 -10
- langgraph_api/js/src/auth-mutations.mts +71 -0
- langgraph_api/lc_security/transport.py +1 -1
- langgraph_api/schema.py +4 -0
- langgraph_api/state.py +11 -5
- {langgraph_api-0.16.0.dev7.dist-info → langgraph_api-0.16.0.dev9.dist-info}/METADATA +2 -1
- {langgraph_api-0.16.0.dev7.dist-info → langgraph_api-0.16.0.dev9.dist-info}/RECORD +20 -19
- langgraph_grpc_common/proto/core_api_pb2.py +154 -154
- langgraph_grpc_common/proto/core_api_pb2.pyi +10 -2
- openapi.json +4 -0
- {langgraph_api-0.16.0.dev7.dist-info → langgraph_api-0.16.0.dev9.dist-info}/WHEEL +0 -0
- {langgraph_api-0.16.0.dev7.dist-info → langgraph_api-0.16.0.dev9.dist-info}/entry_points.txt +0 -0
- {langgraph_api-0.16.0.dev7.dist-info → langgraph_api-0.16.0.dev9.dist-info}/licenses/LICENSE +0 -0
langgraph_api/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.16.0.
|
|
1
|
+
__version__ = "0.16.0.dev9"
|
langgraph_api/api/__init__.py
CHANGED
|
@@ -3,6 +3,8 @@ import functools
|
|
|
3
3
|
import importlib
|
|
4
4
|
import importlib.util
|
|
5
5
|
import os
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
6
8
|
|
|
7
9
|
import structlog
|
|
8
10
|
from starlette.applications import Starlette
|
|
@@ -40,7 +42,14 @@ from langgraph_api.grpc.client import get_shared_client
|
|
|
40
42
|
from langgraph_api.js.base import is_js_path
|
|
41
43
|
from langgraph_api.timing import profiled_import
|
|
42
44
|
from langgraph_api.validation import render_docs_html
|
|
45
|
+
from langgraph_runtime import database as runtime_database
|
|
43
46
|
from langgraph_runtime.database import healthcheck
|
|
47
|
+
from langgraph_runtime.retry import OVERLOADED_EXCEPTIONS
|
|
48
|
+
|
|
49
|
+
HEALTHCHECK_TIMEOUT_SECONDS = 3.0
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from langgraph_api.schema import ServiceHealth
|
|
44
53
|
|
|
45
54
|
logger = structlog.stdlib.get_logger(__name__)
|
|
46
55
|
|
|
@@ -61,6 +70,19 @@ async def grpc_healthcheck():
|
|
|
61
70
|
) from exc
|
|
62
71
|
|
|
63
72
|
|
|
73
|
+
def _data_dependency_checks() -> dict[str, Callable[[], Awaitable[None]]]:
|
|
74
|
+
"""Per-dependency probes, or the combined `healthcheck` as a fallback.
|
|
75
|
+
|
|
76
|
+
Both in-repo runtimes provide `dependency_checks`. `langgraph_runtime`
|
|
77
|
+
resolves its backend from LANGGRAPH_RUNTIME_EDITION at import time, and an
|
|
78
|
+
out-of-tree edition (`community`) is not pinned here, so it may not.
|
|
79
|
+
"""
|
|
80
|
+
checks = getattr(runtime_database, "dependency_checks", None)
|
|
81
|
+
if checks is None:
|
|
82
|
+
return {"database": healthcheck}
|
|
83
|
+
return checks()
|
|
84
|
+
|
|
85
|
+
|
|
64
86
|
async def ok(request: Request, *, disabled: bool = False):
|
|
65
87
|
if disabled:
|
|
66
88
|
# We still expose an /ok endpoint even if disable_meta is set so that
|
|
@@ -68,23 +90,46 @@ async def ok(request: Request, *, disabled: bool = False):
|
|
|
68
90
|
return JSONResponse({"ok": True})
|
|
69
91
|
check_db = int(request.query_params.get("check_db", "0")) # must be "0" or "1"
|
|
70
92
|
|
|
71
|
-
|
|
93
|
+
checks: dict[str, Callable[[], Awaitable[None]]] = {}
|
|
94
|
+
services: dict[str, ServiceHealth] = {}
|
|
72
95
|
|
|
96
|
+
data_checks = _data_dependency_checks()
|
|
73
97
|
if check_db:
|
|
74
|
-
|
|
98
|
+
checks.update(data_checks)
|
|
99
|
+
else:
|
|
100
|
+
services.update(dict.fromkeys(data_checks, "skipped"))
|
|
75
101
|
|
|
76
102
|
if js_bg_tasks:
|
|
77
103
|
from langgraph_api.js.remote import js_healthcheck # noqa: PLC0415
|
|
78
104
|
|
|
79
|
-
|
|
105
|
+
checks["js_runtime"] = js_healthcheck
|
|
80
106
|
|
|
81
107
|
# Check `core-api` server health
|
|
82
108
|
if IS_POSTGRES_OR_GRPC_BACKEND:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
await asyncio.gather(
|
|
86
|
-
|
|
87
|
-
|
|
109
|
+
checks["core_api"] = grpc_healthcheck
|
|
110
|
+
|
|
111
|
+
results = await asyncio.gather(
|
|
112
|
+
*(
|
|
113
|
+
asyncio.wait_for(check(), timeout=HEALTHCHECK_TIMEOUT_SECONDS)
|
|
114
|
+
for check in checks.values()
|
|
115
|
+
),
|
|
116
|
+
return_exceptions=True,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
failed = False
|
|
120
|
+
for name, result in zip(checks, results, strict=True):
|
|
121
|
+
if isinstance(result, BaseException):
|
|
122
|
+
failed = True
|
|
123
|
+
services[name] = "unhealthy"
|
|
124
|
+
logger.warning("Healthcheck failed", service=name, exc_info=result)
|
|
125
|
+
else:
|
|
126
|
+
services[name] = "healthy"
|
|
127
|
+
|
|
128
|
+
overloaded = any(isinstance(result, OVERLOADED_EXCEPTIONS) for result in results)
|
|
129
|
+
return JSONResponse(
|
|
130
|
+
{"ok": not failed, "services": services},
|
|
131
|
+
status_code=503 if overloaded else 500 if failed else 200,
|
|
132
|
+
)
|
|
88
133
|
|
|
89
134
|
|
|
90
135
|
async def openapi(request: Request):
|
langgraph_api/api/a2a.py
CHANGED
|
@@ -1233,21 +1233,18 @@ def _create_interrupt_artifact(interrupts: list[dict[str, Any]]) -> dict[str, An
|
|
|
1233
1233
|
"""Create an A2A artifact from interrupt data.
|
|
1234
1234
|
|
|
1235
1235
|
Args:
|
|
1236
|
-
interrupts: List of interrupt objects with 'id' and 'value' keys
|
|
1236
|
+
interrupts: List of interrupt objects with 'id' and 'value' keys and an
|
|
1237
|
+
optional 'response_schema'
|
|
1237
1238
|
|
|
1238
1239
|
Returns:
|
|
1239
1240
|
A2A artifact dict with interrupt data parts
|
|
1240
1241
|
"""
|
|
1241
|
-
interrupt_parts = [
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
},
|
|
1248
|
-
}
|
|
1249
|
-
for interrupt_obj in interrupts
|
|
1250
|
-
]
|
|
1242
|
+
interrupt_parts = []
|
|
1243
|
+
for interrupt_obj in interrupts:
|
|
1244
|
+
data = {"id": interrupt_obj.get("id"), "value": interrupt_obj.get("value")}
|
|
1245
|
+
if (schema := interrupt_obj.get("response_schema")) is not None:
|
|
1246
|
+
data["response_schema"] = schema
|
|
1247
|
+
interrupt_parts.append({"kind": "data", "data": data})
|
|
1251
1248
|
return {
|
|
1252
1249
|
"artifactId": str(uuid.uuid4()),
|
|
1253
1250
|
"name": "Interrupt",
|
langgraph_api/api/runs.py
CHANGED
|
@@ -96,6 +96,23 @@ _StreamHandler = Any
|
|
|
96
96
|
|
|
97
97
|
_RunResultFallback = Callable[[], Awaitable[bytes]]
|
|
98
98
|
|
|
99
|
+
_INTERRUPT_KEY = "__interrupt__"
|
|
100
|
+
_INTERRUPT_TOKEN = _INTERRUPT_KEY.encode()
|
|
101
|
+
_STATE_MODES = (b"values", b"updates")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _interrupts_in(chunk: bytes) -> list[Any]:
|
|
105
|
+
if _INTERRUPT_TOKEN not in chunk:
|
|
106
|
+
return []
|
|
107
|
+
try:
|
|
108
|
+
payload = orjson.loads(chunk)
|
|
109
|
+
except orjson.JSONDecodeError:
|
|
110
|
+
return []
|
|
111
|
+
if not isinstance(payload, dict):
|
|
112
|
+
return []
|
|
113
|
+
interrupts = payload.get(_INTERRUPT_KEY)
|
|
114
|
+
return interrupts if isinstance(interrupts, list) else []
|
|
115
|
+
|
|
99
116
|
|
|
100
117
|
def _thread_values_fallback(thread_id: UUID) -> _RunResultFallback:
|
|
101
118
|
async def fetch_thread_values() -> bytes:
|
|
@@ -122,7 +139,7 @@ def _thread_values_fallback(thread_id: UUID) -> _RunResultFallback:
|
|
|
122
139
|
if isinstance(interrupt_list, list):
|
|
123
140
|
interrupts.extend(interrupt_list)
|
|
124
141
|
if interrupts:
|
|
125
|
-
return json_dumpb({
|
|
142
|
+
return json_dumpb({_INTERRUPT_KEY: interrupts})
|
|
126
143
|
except Exception:
|
|
127
144
|
# No interrupt, but status is interrupted from a before/after block. Default back to values.
|
|
128
145
|
pass
|
|
@@ -153,17 +170,58 @@ def _merge_feedback(body: bytes, feedback: bytes | None) -> bytes:
|
|
|
153
170
|
def _merge_interrupts(chunks: list[bytes]) -> bytes:
|
|
154
171
|
interrupts: list[Any] = []
|
|
155
172
|
for chunk in chunks:
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
173
|
+
interrupts.extend(_interrupts_in(chunk))
|
|
174
|
+
|
|
175
|
+
return orjson.dumps({_INTERRUPT_KEY: interrupts})
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class _RunResult:
|
|
179
|
+
__slots__ = ("_errored", "_fallback", "_feedback", "_interrupt_chunks", "_latest")
|
|
180
|
+
|
|
181
|
+
def __init__(self, fallback: _RunResultFallback | None) -> None:
|
|
182
|
+
self._errored = False
|
|
183
|
+
self._fallback = fallback
|
|
184
|
+
self._feedback: bytes | None = None
|
|
185
|
+
self._interrupt_chunks: list[bytes] = []
|
|
186
|
+
self._latest: bytes | None = None
|
|
187
|
+
|
|
188
|
+
def absorb(self, mode: bytes, chunk: bytes) -> None:
|
|
189
|
+
if mode == b"error":
|
|
190
|
+
self._latest = orjson.dumps({"__error__": orjson.Fragment(chunk)})
|
|
191
|
+
self._errored = True
|
|
192
|
+
elif mode == b"feedback":
|
|
193
|
+
self._feedback = chunk
|
|
194
|
+
elif mode in _STATE_MODES:
|
|
195
|
+
self._absorb_state(mode, chunk)
|
|
196
|
+
|
|
197
|
+
def _absorb_state(self, mode: bytes, chunk: bytes) -> None:
|
|
198
|
+
if _interrupts_in(chunk):
|
|
199
|
+
self._interrupt_chunks.append(chunk)
|
|
200
|
+
self._latest = chunk
|
|
201
|
+
elif mode == b"values":
|
|
202
|
+
self._latest = chunk
|
|
203
|
+
|
|
204
|
+
async def to_body(self) -> bytes:
|
|
205
|
+
values = await self._final_values()
|
|
206
|
+
if values is not None:
|
|
207
|
+
return _merge_feedback(values, self._feedback)
|
|
208
|
+
if self._feedback is not None:
|
|
209
|
+
return orjson.dumps({"__feedback__": orjson.loads(self._feedback)})
|
|
210
|
+
return b"{}"
|
|
165
211
|
|
|
166
|
-
|
|
212
|
+
async def _final_values(self) -> bytes | None:
|
|
213
|
+
if (interrupted := self._interrupt_values()) is not None:
|
|
214
|
+
return interrupted
|
|
215
|
+
if self._latest is not None:
|
|
216
|
+
return self._latest
|
|
217
|
+
return await self._fallback() if self._fallback is not None else None
|
|
218
|
+
|
|
219
|
+
def _interrupt_values(self) -> bytes | None:
|
|
220
|
+
if self._errored or not self._interrupt_chunks:
|
|
221
|
+
return None
|
|
222
|
+
if len(self._interrupt_chunks) > 1:
|
|
223
|
+
return _merge_interrupts(self._interrupt_chunks)
|
|
224
|
+
return self._interrupt_chunks[-1]
|
|
167
225
|
|
|
168
226
|
|
|
169
227
|
def _run_result_body(
|
|
@@ -179,11 +237,7 @@ def _run_result_body(
|
|
|
179
237
|
last_chunk = ValueEvent()
|
|
180
238
|
|
|
181
239
|
async def consume() -> None:
|
|
182
|
-
|
|
183
|
-
fchunk: bytes | None = None
|
|
184
|
-
interrupt_chunks: list[bytes] = []
|
|
185
|
-
saw_error = False
|
|
186
|
-
|
|
240
|
+
result = _RunResult(fallback)
|
|
187
241
|
try:
|
|
188
242
|
async for mode, chunk, _ in Runs.Stream.join(
|
|
189
243
|
run_id,
|
|
@@ -192,33 +246,9 @@ def _run_result_body(
|
|
|
192
246
|
thread_id=thread_id,
|
|
193
247
|
ignore_404=ignore_404,
|
|
194
248
|
):
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
vchunk = chunk
|
|
199
|
-
if b"__interrupt__" in chunk:
|
|
200
|
-
interrupt_chunks.append(chunk)
|
|
201
|
-
elif mode == b"error":
|
|
202
|
-
vchunk = orjson.dumps({"__error__": orjson.Fragment(chunk)})
|
|
203
|
-
saw_error = True
|
|
204
|
-
elif mode == b"feedback":
|
|
205
|
-
fchunk = chunk
|
|
206
|
-
|
|
207
|
-
# Preserve terminal error precedence over interrupt chunks.
|
|
208
|
-
if not saw_error:
|
|
209
|
-
if len(interrupt_chunks) > 1:
|
|
210
|
-
vchunk = _merge_interrupts(interrupt_chunks)
|
|
211
|
-
elif interrupt_chunks:
|
|
212
|
-
vchunk = interrupt_chunks[-1]
|
|
213
|
-
elif vchunk is None and fallback is not None:
|
|
214
|
-
vchunk = await fallback()
|
|
215
|
-
|
|
216
|
-
if vchunk is not None:
|
|
217
|
-
last_chunk.set(_merge_feedback(vchunk, fchunk))
|
|
218
|
-
elif fchunk is not None:
|
|
219
|
-
last_chunk.set(orjson.dumps({"__feedback__": orjson.loads(fchunk)}))
|
|
220
|
-
else:
|
|
221
|
-
last_chunk.set(b"{}")
|
|
249
|
+
result.absorb(mode, chunk)
|
|
250
|
+
|
|
251
|
+
last_chunk.set(await result.to_body())
|
|
222
252
|
finally:
|
|
223
253
|
await sub.__aexit__(None, None, None)
|
|
224
254
|
|
langgraph_api/api/store.py
CHANGED
|
@@ -24,13 +24,29 @@ from langgraph_api.validation import (
|
|
|
24
24
|
from langgraph_runtime.retry import retry_db
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
def _validate_namespace(namespace:
|
|
27
|
+
def _validate_namespace(namespace: Any) -> Response | None:
|
|
28
|
+
if not isinstance(namespace, list | tuple):
|
|
29
|
+
return _rejected_namespace("Namespace must be a list of labels")
|
|
28
30
|
for label in namespace:
|
|
29
|
-
if not label or "." in label:
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
content=f"Namespace labels cannot be empty or contain periods. Received: {namespace}",
|
|
31
|
+
if not isinstance(label, str) or not label or "." in label:
|
|
32
|
+
return _rejected_namespace(
|
|
33
|
+
f"Namespace label {label!r} must be a non-empty string without periods"
|
|
33
34
|
)
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _validate_optional_namespace(namespace: Any) -> Response | None:
|
|
39
|
+
return None if namespace is None else _validate_namespace(namespace)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _as_namespace(
|
|
43
|
+
namespace: list[str] | tuple[str, ...] | None,
|
|
44
|
+
) -> tuple[str, ...] | None:
|
|
45
|
+
return None if namespace is None else tuple(namespace)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _rejected_namespace(detail: str) -> Response:
|
|
49
|
+
return Response(status_code=422, content=detail)
|
|
34
50
|
|
|
35
51
|
|
|
36
52
|
async def handle_event(
|
|
@@ -72,8 +88,10 @@ async def put_item(request: ApiRequest):
|
|
|
72
88
|
"ttl": payload.get("ttl"),
|
|
73
89
|
}
|
|
74
90
|
await handle_event("put", handler_payload)
|
|
91
|
+
if err := _validate_namespace(handler_payload["namespace"]):
|
|
92
|
+
return err
|
|
75
93
|
await (await get_store()).aput(
|
|
76
|
-
handler_payload["namespace"],
|
|
94
|
+
tuple(handler_payload["namespace"]),
|
|
77
95
|
handler_payload["key"],
|
|
78
96
|
handler_payload["value"],
|
|
79
97
|
index=handler_payload["index"],
|
|
@@ -100,8 +118,10 @@ async def get_item(request: ApiRequest):
|
|
|
100
118
|
else None,
|
|
101
119
|
}
|
|
102
120
|
await handle_event("get", handler_payload)
|
|
121
|
+
if err := _validate_namespace(handler_payload["namespace"]):
|
|
122
|
+
return err
|
|
103
123
|
result = await (await get_store()).aget(
|
|
104
|
-
handler_payload["namespace"],
|
|
124
|
+
tuple(handler_payload["namespace"]),
|
|
105
125
|
handler_payload["key"],
|
|
106
126
|
refresh_ttl=handler_payload["refresh_ttl"],
|
|
107
127
|
)
|
|
@@ -129,8 +149,10 @@ async def delete_item(request: ApiRequest):
|
|
|
129
149
|
"key": payload["key"],
|
|
130
150
|
}
|
|
131
151
|
await handle_event("delete", handler_payload)
|
|
152
|
+
if err := _validate_namespace(handler_payload["namespace"]):
|
|
153
|
+
return err
|
|
132
154
|
await (await get_store()).adelete(
|
|
133
|
-
handler_payload["namespace"], handler_payload["key"]
|
|
155
|
+
tuple(handler_payload["namespace"]), handler_payload["key"]
|
|
134
156
|
)
|
|
135
157
|
return Response(status_code=204)
|
|
136
158
|
|
|
@@ -155,6 +177,8 @@ async def search_items(request: ApiRequest):
|
|
|
155
177
|
"refresh_ttl": payload.get("refresh_ttl"),
|
|
156
178
|
}
|
|
157
179
|
auth_filter = await handle_event("search", handler_payload)
|
|
180
|
+
if err := _validate_namespace(handler_payload["namespace"]):
|
|
181
|
+
return err
|
|
158
182
|
if auth_filter:
|
|
159
183
|
existing = handler_payload.get("filter")
|
|
160
184
|
if existing:
|
|
@@ -162,7 +186,7 @@ async def search_items(request: ApiRequest):
|
|
|
162
186
|
else:
|
|
163
187
|
handler_payload["filter"] = auth_filter
|
|
164
188
|
items = await (await get_store()).asearch(
|
|
165
|
-
handler_payload["namespace"],
|
|
189
|
+
tuple(handler_payload["namespace"]),
|
|
166
190
|
filter=handler_payload["filter"],
|
|
167
191
|
limit=handler_payload["limit"],
|
|
168
192
|
offset=handler_payload["offset"],
|
|
@@ -187,10 +211,9 @@ async def list_namespaces(request: ApiRequest):
|
|
|
187
211
|
payload = await request.json(StoreListNamespacesRequest)
|
|
188
212
|
prefix = tuple(payload["prefix"]) if payload.get("prefix") else None
|
|
189
213
|
suffix = tuple(payload["suffix"]) if payload.get("suffix") else None
|
|
190
|
-
err
|
|
191
|
-
if prefix and (err := _validate_namespace(prefix)):
|
|
214
|
+
if err := _validate_optional_namespace(prefix):
|
|
192
215
|
return err
|
|
193
|
-
if
|
|
216
|
+
if err := _validate_optional_namespace(suffix):
|
|
194
217
|
return err
|
|
195
218
|
max_depth = payload.get("max_depth")
|
|
196
219
|
limit = payload.get("limit", 100)
|
|
@@ -203,9 +226,12 @@ async def list_namespaces(request: ApiRequest):
|
|
|
203
226
|
"offset": offset,
|
|
204
227
|
}
|
|
205
228
|
await handle_event("list_namespaces", handler_payload)
|
|
229
|
+
for candidate in (handler_payload["namespace"], handler_payload["suffix"]):
|
|
230
|
+
if err := _validate_optional_namespace(candidate):
|
|
231
|
+
return err
|
|
206
232
|
result = await (await get_store()).alist_namespaces(
|
|
207
|
-
prefix=handler_payload["namespace"],
|
|
208
|
-
suffix=handler_payload["suffix"],
|
|
233
|
+
prefix=_as_namespace(handler_payload["namespace"]),
|
|
234
|
+
suffix=_as_namespace(handler_payload["suffix"]),
|
|
209
235
|
max_depth=handler_payload["max_depth"],
|
|
210
236
|
limit=handler_payload["limit"],
|
|
211
237
|
offset=handler_payload["offset"],
|
|
@@ -201,6 +201,8 @@ def _proto_interrupts_to_dict(
|
|
|
201
201
|
entry["resumable"] = interrupt.resumable
|
|
202
202
|
if interrupt.ns:
|
|
203
203
|
entry["ns"] = list(interrupt.ns)
|
|
204
|
+
if interrupt.response_schema:
|
|
205
|
+
entry["response_schema"] = json_loads(interrupt.response_schema)
|
|
204
206
|
entries.append(entry)
|
|
205
207
|
out[key] = entries
|
|
206
208
|
return out
|
langgraph_api/js/client.mts
CHANGED
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
getStaticGraphSchema,
|
|
60
60
|
} from "@langchain/langgraph-api/schema";
|
|
61
61
|
import { filterValidExportPath } from "./src/utils/files.mts";
|
|
62
|
+
import { collectMutations } from "./src/auth-mutations.mts";
|
|
62
63
|
import { patchFetch } from "./traceblock.mts";
|
|
63
64
|
import { writeHeapSnapshot } from "node:v8";
|
|
64
65
|
|
|
@@ -1278,7 +1279,13 @@ async function main() {
|
|
|
1278
1279
|
|
|
1279
1280
|
app.post("/auth/authorize", async (c) => {
|
|
1280
1281
|
try {
|
|
1281
|
-
|
|
1282
|
+
const request = await c.req.json();
|
|
1283
|
+
const before = structuredClone(request.value);
|
|
1284
|
+
const { value, ...result } = await authorize(request);
|
|
1285
|
+
return c.json({
|
|
1286
|
+
...result,
|
|
1287
|
+
mutations: collectMutations(before, value),
|
|
1288
|
+
});
|
|
1282
1289
|
} catch (error) {
|
|
1283
1290
|
if (error instanceof HTTPException) {
|
|
1284
1291
|
return c.json(serializeError(error), error.status);
|
langgraph_api/js/remote.py
CHANGED
|
@@ -1238,19 +1238,104 @@ async def handle_js_auth_event(
|
|
|
1238
1238
|
|
|
1239
1239
|
filters = cast("Auth.types.FilterType | None", response.get("filters"))
|
|
1240
1240
|
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1241
|
+
try:
|
|
1242
|
+
_apply_auth_mutations(value, response.get("mutations"))
|
|
1243
|
+
except _RefusedAuthMutation as error:
|
|
1244
|
+
raise HTTPException(
|
|
1245
|
+
status_code=500,
|
|
1246
|
+
detail=f"JS auth handler result could not be applied: {error}",
|
|
1247
|
+
) from error
|
|
1248
|
+
|
|
1249
|
+
return filters
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
_MutationKey = str | int
|
|
1253
|
+
_MutationContainer = dict[str, Any] | list[Any] | tuple[Any, ...]
|
|
1254
|
+
_UNADDRESSABLE_MUTATION = "a mutation does not address the request payload"
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
class _RefusedAuthMutation(Exception):
|
|
1258
|
+
pass
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _apply_auth_mutations(payload: dict[str, Any], mutations: Any) -> None:
|
|
1262
|
+
if not isinstance(mutations, list):
|
|
1263
|
+
raise _RefusedAuthMutation("the sidecar returned no mutation list")
|
|
1264
|
+
for mutation in mutations:
|
|
1265
|
+
path = _mutation_path(mutation)
|
|
1266
|
+
if "value" in mutation:
|
|
1267
|
+
_write(payload, path, mutation["value"])
|
|
1268
|
+
elif len(path) > 1:
|
|
1269
|
+
_remove(payload, path)
|
|
1270
|
+
else:
|
|
1271
|
+
raise _RefusedAuthMutation(
|
|
1272
|
+
f"a mutation removes the route-owned key {path[0]!r}"
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def _mutation_path(mutation: Any) -> list[_MutationKey]:
|
|
1277
|
+
path = mutation.get("path") if isinstance(mutation, dict) else None
|
|
1245
1278
|
if (
|
|
1246
|
-
isinstance(
|
|
1247
|
-
and
|
|
1248
|
-
and isinstance(
|
|
1249
|
-
and (metadata := updated_value.get("metadata"))
|
|
1279
|
+
isinstance(path, list)
|
|
1280
|
+
and path
|
|
1281
|
+
and all(isinstance(key, str) or type(key) is int for key in path)
|
|
1250
1282
|
):
|
|
1251
|
-
|
|
1283
|
+
return path
|
|
1284
|
+
raise _RefusedAuthMutation("a mutation is malformed")
|
|
1252
1285
|
|
|
1253
|
-
|
|
1286
|
+
|
|
1287
|
+
def _write(container: _MutationContainer, path: list[_MutationKey], value: Any) -> Any:
|
|
1288
|
+
key = path[0]
|
|
1289
|
+
if len(path) > 1:
|
|
1290
|
+
return _with_member(
|
|
1291
|
+
container, key, _write(_child(container, key), path[1:], value)
|
|
1292
|
+
)
|
|
1293
|
+
existing = _member(container, key)
|
|
1294
|
+
return _with_member(container, key, _keeping_existing_type(existing, value))
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def _remove(container: _MutationContainer, path: list[_MutationKey]) -> Any:
|
|
1298
|
+
key = path[0]
|
|
1299
|
+
if len(path) > 1:
|
|
1300
|
+
return _with_member(container, key, _remove(_child(container, key), path[1:]))
|
|
1301
|
+
if not (isinstance(container, dict) and _addresses(container, key)):
|
|
1302
|
+
raise _RefusedAuthMutation(_UNADDRESSABLE_MUTATION)
|
|
1303
|
+
container.pop(key, None)
|
|
1304
|
+
return container
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def _addresses(container: Any, key: _MutationKey) -> bool:
|
|
1308
|
+
if isinstance(container, dict):
|
|
1309
|
+
return isinstance(key, str)
|
|
1310
|
+
if isinstance(container, list | tuple):
|
|
1311
|
+
return type(key) is int and 0 <= key < len(container)
|
|
1312
|
+
return False
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def _member(container: _MutationContainer, key: _MutationKey) -> Any:
|
|
1316
|
+
if not _addresses(container, key):
|
|
1317
|
+
raise _RefusedAuthMutation(_UNADDRESSABLE_MUTATION)
|
|
1318
|
+
return container.get(key) if isinstance(container, dict) else container[key]
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _child(container: _MutationContainer, key: _MutationKey) -> _MutationContainer:
|
|
1322
|
+
child = _member(container, key)
|
|
1323
|
+
if not isinstance(child, dict | list | tuple):
|
|
1324
|
+
raise _RefusedAuthMutation(_UNADDRESSABLE_MUTATION)
|
|
1325
|
+
return child
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _with_member(container: _MutationContainer, key: _MutationKey, value: Any) -> Any:
|
|
1329
|
+
if isinstance(container, tuple):
|
|
1330
|
+
return (*container[:key], value, *container[key + 1 :])
|
|
1331
|
+
container[key] = value
|
|
1332
|
+
return container
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def _keeping_existing_type(existing: Any, replacement: Any) -> Any:
|
|
1336
|
+
if isinstance(existing, tuple) and isinstance(replacement, list):
|
|
1337
|
+
return tuple(replacement)
|
|
1338
|
+
return replacement
|
|
1254
1339
|
|
|
1255
1340
|
|
|
1256
1341
|
class JSCustomHTTPProxyMiddleware:
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type MutationPath = (string | number)[];
|
|
2
|
+
export type AuthMutation = { path: MutationPath; value?: unknown };
|
|
3
|
+
|
|
4
|
+
type JsonObject = Record<string, unknown>;
|
|
5
|
+
type JsonContainer = JsonObject | unknown[];
|
|
6
|
+
|
|
7
|
+
const isJsonObject = (value: unknown): value is JsonObject =>
|
|
8
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
|
|
10
|
+
const areAligned = (left: unknown, right: unknown): left is JsonContainer =>
|
|
11
|
+
(isJsonObject(left) && isJsonObject(right)) ||
|
|
12
|
+
(Array.isArray(left) && Array.isArray(right) && left.length === right.length);
|
|
13
|
+
|
|
14
|
+
const keysOf = (container: JsonContainer): MutationPath =>
|
|
15
|
+
Array.isArray(container)
|
|
16
|
+
? Array.from({ length: container.length }, (_, index) => index)
|
|
17
|
+
: Object.keys(container);
|
|
18
|
+
|
|
19
|
+
const hasMember = (container: JsonContainer, key: string | number): boolean =>
|
|
20
|
+
Array.isArray(container)
|
|
21
|
+
? Number(key) >= 0 && Number(key) < container.length
|
|
22
|
+
: Object.hasOwn(container, key);
|
|
23
|
+
|
|
24
|
+
const memberOf = (container: JsonContainer, key: string | number): unknown => {
|
|
25
|
+
if (!Array.isArray(container)) {
|
|
26
|
+
return hasMember(container, key) ? container[String(key)] : undefined;
|
|
27
|
+
}
|
|
28
|
+
const element = container[Number(key)];
|
|
29
|
+
return element === undefined ? null : element;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const collectInto = (
|
|
33
|
+
before: JsonContainer,
|
|
34
|
+
after: JsonContainer,
|
|
35
|
+
path: MutationPath,
|
|
36
|
+
found: AuthMutation[],
|
|
37
|
+
): void => {
|
|
38
|
+
for (const key of keysOf(before)) {
|
|
39
|
+
if (!hasMember(after, key) || memberOf(after, key) === undefined) {
|
|
40
|
+
found.push({ path: [...path, key] });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const key of keysOf(after)) {
|
|
44
|
+
const replacement = memberOf(after, key);
|
|
45
|
+
if (replacement === undefined) continue;
|
|
46
|
+
const existing = memberOf(before, key);
|
|
47
|
+
if (areAligned(existing, replacement)) {
|
|
48
|
+
collectInto(
|
|
49
|
+
existing,
|
|
50
|
+
replacement as JsonContainer,
|
|
51
|
+
[...path, key],
|
|
52
|
+
found,
|
|
53
|
+
);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!hasMember(before, key) || existing !== replacement) {
|
|
57
|
+
found.push({ path: [...path, key], value: replacement });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const collectMutations = (
|
|
63
|
+
before: unknown,
|
|
64
|
+
after: unknown,
|
|
65
|
+
): AuthMutation[] => {
|
|
66
|
+
const found: AuthMutation[] = [];
|
|
67
|
+
if (areAligned(before, after)) {
|
|
68
|
+
collectInto(before, after as JsonContainer, [], found);
|
|
69
|
+
}
|
|
70
|
+
return found;
|
|
71
|
+
};
|
|
@@ -145,7 +145,7 @@ class SSRFSafeTransport(httpx.AsyncBaseTransport):
|
|
|
145
145
|
# certificate validation uses the original hostname.
|
|
146
146
|
extensions = dict(request.extensions)
|
|
147
147
|
if scheme == "https":
|
|
148
|
-
extensions["sni_hostname"] = hostname
|
|
148
|
+
extensions["sni_hostname"] = hostname
|
|
149
149
|
|
|
150
150
|
pinned_request = httpx.Request(
|
|
151
151
|
method=request.method,
|
langgraph_api/schema.py
CHANGED
|
@@ -15,6 +15,8 @@ RunStatus = Literal["pending", "running", "error", "success", "timeout", "interr
|
|
|
15
15
|
|
|
16
16
|
ThreadStatus = Literal["idle", "busy", "interrupted", "error"]
|
|
17
17
|
|
|
18
|
+
ServiceHealth = Literal["healthy", "unhealthy", "skipped"]
|
|
19
|
+
|
|
18
20
|
StreamMode = Literal[
|
|
19
21
|
"values",
|
|
20
22
|
"messages",
|
|
@@ -134,6 +136,8 @@ class Interrupt(TypedDict):
|
|
|
134
136
|
"""The ID of the interrupt."""
|
|
135
137
|
value: Any
|
|
136
138
|
"""The value of the interrupt."""
|
|
139
|
+
response_schema: NotRequired[dict[str, Any]]
|
|
140
|
+
"""JSON Schema for the value expected when resuming the interrupt, if the graph provided one."""
|
|
137
141
|
|
|
138
142
|
|
|
139
143
|
class DeprecatedInterrupt(TypedDict, total=False):
|