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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.16.0.dev7"
1
+ __version__ = "0.16.0.dev9"
@@ -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
- healthcheck_coroutines = []
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
- healthcheck_coroutines.append(healthcheck())
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
- healthcheck_coroutines.append(js_healthcheck())
105
+ checks["js_runtime"] = js_healthcheck
80
106
 
81
107
  # Check `core-api` server health
82
108
  if IS_POSTGRES_OR_GRPC_BACKEND:
83
- healthcheck_coroutines.append(grpc_healthcheck())
84
-
85
- await asyncio.gather(*healthcheck_coroutines)
86
-
87
- return JSONResponse({"ok": True})
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
- "kind": "data",
1244
- "data": {
1245
- "id": interrupt_obj.get("id"),
1246
- "value": interrupt_obj.get("value"),
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({"__interrupt__": interrupts})
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
- try:
157
- payload = orjson.loads(chunk)
158
- except orjson.JSONDecodeError:
159
- continue
160
- if not isinstance(payload, dict):
161
- continue
162
- interrupt_list = payload.get("__interrupt__")
163
- if isinstance(interrupt_list, list):
164
- interrupts.extend(interrupt_list)
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
- return orjson.dumps({"__interrupt__": interrupts})
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
- vchunk: bytes | None = None
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
- if mode == b"values" or (
196
- mode == b"updates" and b"__interrupt__" in chunk
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
 
@@ -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: tuple[str, ...]) -> Response | None:
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 Response(
31
- status_code=422,
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 = None
191
- if prefix and (err := _validate_namespace(prefix)):
214
+ if err := _validate_optional_namespace(prefix):
192
215
  return err
193
- if suffix and (err := _validate_namespace(suffix)):
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
@@ -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
- return c.json(await authorize(await c.req.json()));
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);
@@ -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
- # mutate metadata in value if applicable
1242
- # we need to preserve the identity of the object, so cannot create a new
1243
- # dictionary, otherwise the changes will not persist
1244
- metadata = None
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(value, dict)
1247
- and (updated_value := response.get("value"))
1248
- and isinstance(value.get("metadata"), dict)
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
- value["metadata"].update(metadata)
1283
+ return path
1284
+ raise _RefusedAuthMutation("a mutation is malformed")
1252
1285
 
1253
- return filters
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.encode("ascii")
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):