langgraph-api 0.18.0.dev2__py3-none-any.whl → 0.18.0.dev4__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 (48) hide show
  1. langgraph_api/__init__.py +1 -1
  2. langgraph_api/api/a2a.py +93 -10
  3. langgraph_api/api/event_streaming.py +81 -9
  4. langgraph_api/api/mcp/_calls.py +29 -0
  5. langgraph_api/api/mcp/_constants.py +15 -6
  6. langgraph_api/api/mcp/_handlers.py +146 -196
  7. langgraph_api/api/mcp/_outcome.py +43 -0
  8. langgraph_api/api/mcp/_protocol.py +224 -0
  9. langgraph_api/config/__init__.py +38 -4
  10. langgraph_api/cron_scheduler.py +2 -2
  11. langgraph_api/drain.py +164 -0
  12. langgraph_api/event_streaming/service.py +130 -133
  13. langgraph_api/event_streaming/session.py +11 -6
  14. langgraph_api/executor/__init__.py +9 -0
  15. langgraph_api/executor/__main__.py +343 -0
  16. langgraph_api/feature_flags.py +6 -0
  17. langgraph_api/graph.py +12 -2
  18. langgraph_api/grpc/ops/runs.py +41 -5
  19. langgraph_api/grpc/ops/threads.py +54 -6
  20. langgraph_api/metrics_otlp.py +8 -0
  21. langgraph_api/models/run.py +11 -3
  22. langgraph_api/queue_entrypoint.py +13 -1
  23. langgraph_api/self_hosted_logs.py +3 -1
  24. langgraph_api/self_hosted_metrics.py +1 -1
  25. langgraph_api/stream.py +49 -3
  26. langgraph_api/timing/__init__.py +16 -0
  27. langgraph_api/timing/startup.py +184 -0
  28. langgraph_api/timing/timer.py +16 -2
  29. langgraph_api/utils/headers.py +20 -3
  30. langgraph_api/validation.py +11 -5
  31. langgraph_api/worker.py +146 -13
  32. langgraph_api/worker_runtime.py +124 -4
  33. {langgraph_api-0.18.0.dev2.dist-info → langgraph_api-0.18.0.dev4.dist-info}/METADATA +1 -1
  34. {langgraph_api-0.18.0.dev2.dist-info → langgraph_api-0.18.0.dev4.dist-info}/RECORD +43 -38
  35. langgraph_grpc_common/proto/__init__.py +4 -4
  36. langgraph_grpc_common/proto/orchestrator_pb2.py +50 -0
  37. langgraph_grpc_common/proto/{runner_pb2.pyi → orchestrator_pb2.pyi} +4 -4
  38. langgraph_grpc_common/proto/orchestrator_pb2_grpc.py +108 -0
  39. langgraph_grpc_common/proto/orchestrator_pb2_grpc.pyi +82 -0
  40. openapi.json +20 -0
  41. langgraph_api/runner/__init__.py +0 -10
  42. langgraph_api/runner/__main__.py +0 -275
  43. langgraph_grpc_common/proto/runner_pb2.py +0 -50
  44. langgraph_grpc_common/proto/runner_pb2_grpc.py +0 -108
  45. langgraph_grpc_common/proto/runner_pb2_grpc.pyi +0 -73
  46. {langgraph_api-0.18.0.dev2.dist-info → langgraph_api-0.18.0.dev4.dist-info}/WHEEL +0 -0
  47. {langgraph_api-0.18.0.dev2.dist-info → langgraph_api-0.18.0.dev4.dist-info}/entry_points.txt +0 -0
  48. {langgraph_api-0.18.0.dev2.dist-info → langgraph_api-0.18.0.dev4.dist-info}/licenses/LICENSE +0 -0
@@ -3,29 +3,48 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import functools
5
5
  import json
6
- from typing import TYPE_CHECKING, Any, cast
6
+ import re
7
+ from http import HTTPStatus
8
+ from typing import TYPE_CHECKING, Any, assert_never, cast
7
9
 
8
10
  import structlog
9
11
  from langgraph_sdk.client import LangGraphClient, get_client
10
12
  from starlette.responses import JSONResponse, Response
11
13
 
12
- from langgraph_api import __version__
13
- from langgraph_api.api.mcp import _sanitizers
14
+ from langgraph_api.api.mcp import _protocol, _sanitizers
15
+ from langgraph_api.api.mcp._calls import ToolCall
14
16
  from langgraph_api.api.mcp._constants import (
15
17
  DEFAULT_PAGE_SIZE,
16
- ERROR_CODE_INVALID_PARAMS,
18
+ ERROR_CODE_INVALID_REQUEST,
17
19
  ERROR_CODE_METHOD_NOT_FOUND,
18
- LATEST_PROTOCOL_VERSION,
20
+ INVALID_CURSOR_MESSAGE,
21
+ LATEST_LEGACY_PROTOCOL_VERSION,
22
+ LEGACY_PROTOCOL_VERSIONS,
19
23
  MAX_ASSISTANTS,
20
- SUPPORTED_PROTOCOL_VERSIONS,
24
+ MAX_CURSOR_LENGTH,
25
+ MAX_PAGE_OFFSET,
26
+ PAUSED_TOOL_MESSAGE,
21
27
  )
28
+ from langgraph_api.api.mcp._outcome import (
29
+ Completed,
30
+ Failed,
31
+ Paused,
32
+ RunOutcome,
33
+ parse_outcome,
34
+ )
35
+ from langgraph_api.serde import json_dumpb
22
36
 
23
37
  if TYPE_CHECKING:
38
+ from collections.abc import Mapping
39
+
24
40
  from langgraph_api.api.mcp._models import JsonRpcRequest
41
+ from langgraph_api.cache import JsonValue
25
42
  from langgraph_api.route import ApiRequest
26
43
 
27
44
  logger = structlog.stdlib.get_logger(__name__)
28
45
 
46
+ _CURSOR_DIGITS = re.compile(rf"[0-9]{{1,{MAX_CURSOR_LENGTH}}}")
47
+
29
48
 
30
49
  @functools.lru_cache(maxsize=1)
31
50
  def _client() -> LangGraphClient:
@@ -53,54 +72,43 @@ def handle_get_request() -> Response:
53
72
 
54
73
 
55
74
  async def handle_post_request(request: ApiRequest) -> Response:
56
- """Handle HTTP POST requests for JSON-RPC messaging.
57
-
58
- Args:
59
- request: The incoming request object
60
-
61
- Returns:
62
- Response to the JSON-RPC message
63
- """
64
75
  body = await request.body()
65
76
 
66
- # Validate JSON
67
77
  try:
68
78
  message = json.loads(body)
69
79
  except json.JSONDecodeError:
70
80
  return create_error_response("Invalid JSON", 400)
71
81
 
72
- # Validate Accept header
73
82
  if not is_valid_accept_header(request):
74
83
  return create_error_response(
75
84
  "Accept header must include application/json or text/event-stream", 400
76
85
  )
77
86
 
78
- # Validate message format
79
87
  if not isinstance(message, dict):
80
88
  return create_error_response("Invalid message format.", 400)
81
89
 
82
- # Determine message type and route to appropriate handler
83
90
  id_ = message.get("id")
84
91
  method = message.get("method")
92
+ if (method is not None and not isinstance(method, str)) or (
93
+ id_ is not None and (isinstance(id_, bool) or not isinstance(id_, (str, int)))
94
+ ):
95
+ return _jsonrpc_error(
96
+ None,
97
+ _protocol.ProtocolError(
98
+ "Invalid JSON-RPC method or id", ERROR_CODE_INVALID_REQUEST
99
+ ),
100
+ )
85
101
 
86
- # Check for required jsonrpc field
87
102
  if message.get("jsonrpc") != "2.0":
88
103
  return create_error_response(
89
104
  "Invalid JSON-RPC message. Missing or invalid jsonrpc version.", 400
90
105
  )
91
106
 
92
- # Careful ID checks as the integer 0 is a valid ID
93
107
  if id_ is not None and method:
94
- # JSON-RPC request
95
108
  return await handle_jsonrpc_request(request, cast("JsonRpcRequest", message))
96
- elif id_ is not None:
97
- # JSON-RPC response
98
- return Response(status_code=202)
99
- elif method:
100
- # JSON-RPC notification
109
+ elif id_ is not None or method:
101
110
  return Response(status_code=202)
102
111
  else:
103
- # Invalid message format
104
112
  return create_error_response(
105
113
  "Invalid message format. A message is to be either a JSON-RPC "
106
114
  "request, response, or notification."
@@ -146,114 +154,85 @@ async def handle_jsonrpc_request(
146
154
  request: ApiRequest,
147
155
  message: JsonRpcRequest,
148
156
  ) -> Response:
149
- """Handle JSON-RPC requests (messages with both id and method).
150
-
151
- Args:
152
- request: The incoming request object
153
- message: The parsed JSON-RPC message
154
-
155
- Returns:
156
- Response to the request
157
- """
158
- method = message["method"]
159
- params = message.get("params", {})
160
-
161
- if method == "initialize":
162
- result_or_error = handle_initialize_request(message)
163
- elif method == "ping":
164
- result_or_error = {"result": {}}
165
- elif method == "tools/list":
166
- result_or_error = await handle_tools_list(request, params)
167
- elif method == "tools/call":
168
- result_or_error = await handle_tools_call(request, params)
169
- else:
170
- result_or_error = {
171
- "error": {
172
- "code": ERROR_CODE_METHOD_NOT_FOUND,
173
- "message": f"Method not found: {method}",
174
- }
175
- }
176
-
177
- # Process the result or error output
178
- exists = {"error", "result"} - set(result_or_error.keys())
179
- if len(exists) != 1:
180
- raise AssertionError(
181
- "Internal server error. Invalid response in MCP protocol implementation."
157
+ try:
158
+ call = _protocol.parse_call(request, message)
159
+ except _protocol.ProtocolError as error:
160
+ return _jsonrpc_error(message["id"], error)
161
+ try:
162
+ result = await _dispatch(request, call)
163
+ except _protocol.ProtocolError as error:
164
+ return _jsonrpc_error(
165
+ call.id, error, status=call.era.http_status(error), headers=call.era.headers
182
166
  )
167
+ return _result_response(call, result)
168
+
169
+
170
+ async def _dispatch(request: ApiRequest, call: _protocol.McpCall) -> dict[str, Any]:
171
+ match call.era, call.method:
172
+ case _protocol.Modern(), "server/discover":
173
+ return _protocol.discover()
174
+ case _protocol.Legacy(), "initialize":
175
+ return handle_initialize_request(call.message)
176
+ case _protocol.Legacy(), "ping":
177
+ return {}
178
+ case _, "tools/list":
179
+ return await handle_tools_list(request, call.params)
180
+ case _, "tools/call":
181
+ return await handle_tools_call(request, call)
182
+ case _:
183
+ raise _protocol.ProtocolError(
184
+ f"Method not found: {call.method}", ERROR_CODE_METHOD_NOT_FOUND
185
+ )
183
186
 
187
+
188
+ def _result_response(call: _protocol.McpCall, result: dict[str, Any]) -> Response:
184
189
  return JSONResponse(
185
190
  {
186
191
  "jsonrpc": "2.0",
187
- "id": message["id"],
188
- **result_or_error,
189
- }
192
+ "id": call.id,
193
+ "result": call.era.result(call.method, result),
194
+ },
195
+ headers=call.era.headers,
190
196
  )
191
197
 
192
198
 
193
- def _negotiate_protocol_version(requested: str) -> str:
194
- """Negotiate MCP protocol version with the client.
199
+ def _jsonrpc_error(
200
+ id_: str | int | None,
201
+ error: _protocol.ProtocolError,
202
+ *,
203
+ status: HTTPStatus = HTTPStatus.BAD_REQUEST,
204
+ headers: Mapping[str, str] | None = None,
205
+ ) -> Response:
206
+ return JSONResponse(
207
+ {"jsonrpc": "2.0", "id": id_, "error": error.error},
208
+ status_code=status,
209
+ headers=headers,
210
+ )
195
211
 
196
- Returns the requested version if supported, otherwise the latest.
197
- """
198
- if requested in SUPPORTED_PROTOCOL_VERSIONS:
212
+
213
+ def _negotiate_protocol_version(requested: str) -> str:
214
+ if requested in LEGACY_PROTOCOL_VERSIONS:
199
215
  return requested
200
- return LATEST_PROTOCOL_VERSION
216
+ return LATEST_LEGACY_PROTOCOL_VERSION
201
217
 
202
218
 
203
219
  def handle_initialize_request(message: JsonRpcRequest) -> dict[str, Any]:
204
- """Handle initialize requests to establish protocol version.
205
-
206
- Negotiates the protocol version with the client. If the client requests
207
- a supported version, the server echoes it back. Otherwise the server
208
- responds with the latest version it supports and the client may disconnect
209
- if it cannot work with that version.
210
-
211
- Args:
212
- message: The JSON-RPC request message
213
-
214
- Returns:
215
- Response with negotiated protocol details
216
- """
217
- params = message.get("params", {})
218
- requested = params.get("protocolVersion", LATEST_PROTOCOL_VERSION)
219
- negotiated = _negotiate_protocol_version(requested)
220
-
220
+ params = _protocol.params_of(message)
221
+ requested = params.get("protocolVersion", LATEST_LEGACY_PROTOCOL_VERSION)
221
222
  return {
222
- "result": {
223
- "protocolVersion": negotiated,
224
- "capabilities": {
225
- "tools": {
226
- "listChanged": False,
227
- }
228
- },
229
- "serverInfo": {"name": "LangGraph", "version": __version__},
230
- }
223
+ "protocolVersion": _negotiate_protocol_version(requested),
224
+ "capabilities": _protocol.SERVER_CAPABILITIES,
225
+ "serverInfo": _protocol.SERVER_INFO,
231
226
  }
232
227
 
233
228
 
234
229
  async def handle_tools_list(
235
230
  request: ApiRequest, params: dict[str, Any]
236
231
  ) -> dict[str, Any]:
237
- """Handle tools/list request to get available assistants as tools.
238
-
239
- Args:
240
- request: The incoming request object. Used for propagating any headers
241
- for authentication purposes.
242
- params: The parameters for the tools/list request
243
-
244
- Returns:
245
- Dictionary containing list of available tools
246
- """
247
232
  client = _client()
248
233
 
249
- try:
250
- cursor = params.get("cursor", 0)
251
- cursor = int(cursor)
252
- except ValueError:
253
- cursor = 0
234
+ cursor = _page_offset(params.get("cursor", 0))
254
235
 
255
- # Get assistants from the API
256
- # For now set a large limit to get all assistants
257
236
  assistants = await client.assistants.search(
258
237
  offset=cursor, limit=DEFAULT_PAGE_SIZE, headers=request.headers
259
238
  )
@@ -263,9 +242,8 @@ async def handle_tools_list(
263
242
  else:
264
243
  next_cursor = None
265
244
 
266
- # Deduplicate by normalized name, preserving original for the title field
267
245
  seen_names: set[str] = set()
268
- unique: list[tuple[Any, str]] = [] # (assistant, normalized_name)
246
+ unique: list[tuple[Any, str]] = []
269
247
  for assistant in assistants:
270
248
  normalized = _sanitizers.normalize_name(assistant["name"])
271
249
  if normalized in seen_names:
@@ -279,10 +257,6 @@ async def handle_tools_list(
279
257
  unique.append((assistant, normalized))
280
258
 
281
259
  async def _get_tool(assistant: Any, normalized_name: str) -> dict[str, Any] | None:
282
- """Get tool definition for an assistant.
283
-
284
- Returns None if schema generation fails (e.g., non-serializable state).
285
- """
286
260
  try:
287
261
  schemas = await client.assistants.get_schemas(
288
262
  assistant["assistant_id"], headers=request.headers
@@ -292,7 +266,6 @@ async def handle_tools_list(
292
266
  raise ValueError("Input schema generation failed")
293
267
  if input_schema:
294
268
  _sanitizers.simplify_mcp_schema_inplace(input_schema)
295
- # MCP spec requires inputSchema to be a JSON Schema with type "object"
296
269
  input_schema.setdefault("type", "object")
297
270
  return {
298
271
  "name": normalized_name,
@@ -317,99 +290,76 @@ async def handle_tools_list(
317
290
  return None
318
291
 
319
292
  tools_or_none = await asyncio.gather(*(_get_tool(a, n) for a, n in unique))
320
- # Filter out assistants that failed schema generation
321
293
  tools = [t for t in tools_or_none if t is not None]
322
294
 
323
295
  result = {"tools": tools}
324
-
325
296
  if next_cursor is not None:
326
- result["nextCursor"] = next_cursor
297
+ result["nextCursor"] = str(next_cursor)
298
+ return result
327
299
 
328
- return {
329
- "result": result,
330
- }
300
+
301
+ def _page_offset(cursor: object) -> int:
302
+ match cursor:
303
+ case int() if not isinstance(cursor, bool):
304
+ offset = cursor
305
+ case str() if _CURSOR_DIGITS.fullmatch(cursor):
306
+ offset = int(cursor)
307
+ case _:
308
+ raise _protocol.ProtocolError(INVALID_CURSOR_MESSAGE)
309
+ if not 0 <= offset <= MAX_PAGE_OFFSET:
310
+ raise _protocol.ProtocolError(INVALID_CURSOR_MESSAGE)
311
+ return offset
331
312
 
332
313
 
333
314
  async def handle_tools_call(
334
- request: ApiRequest, params: dict[str, Any]
315
+ request: ApiRequest, call: _protocol.McpCall
335
316
  ) -> dict[str, Any]:
336
- """Handle tools/call request to execute an assistant.
337
-
338
- Args:
339
- request: The incoming request
340
- params: The parameters for the tool call
341
-
342
- Returns:
343
- The result of the tool execution
344
- """
345
- client = _client()
346
-
347
- tool_name = params.get("name")
348
-
349
- if not tool_name:
350
- return {
351
- "error": {
352
- "code": ERROR_CODE_INVALID_PARAMS,
353
- "message": f"Unknown tool: {tool_name}",
354
- },
355
- }
356
-
357
- arguments = params.get("arguments", {})
358
- context = params.get("context")
359
- assistants = await client.assistants.search(
360
- limit=MAX_ASSISTANTS, headers=request.headers
361
- )
362
- matching_assistant = []
363
- for assistant in assistants:
364
- if (
365
- normalized_name := _sanitizers.normalize_name(assistant["name"])
366
- ) and normalized_name == tool_name:
367
- matching_assistant.append(assistant)
368
-
369
- num_assistants = len(matching_assistant)
370
-
371
- if num_assistants == 0:
372
- return {
373
- "error": {
374
- "code": ERROR_CODE_INVALID_PARAMS,
375
- "message": f"Unknown tool: {tool_name}",
376
- },
377
- }
378
- elif num_assistants > 1:
379
- return {
380
- "error": {
381
- "code": ERROR_CODE_INVALID_PARAMS,
382
- "message": "Multiple tools found with the same name.",
383
- },
384
- }
385
- else:
386
- tool_name = matching_assistant[0]["assistant_id"]
387
-
388
- value = await client.runs.wait(
317
+ tool_call = ToolCall.of(call.params)
318
+ assistant_id = await _assistant_serving(request, tool_call.name)
319
+ value = await _client().runs.wait(
389
320
  thread_id=None,
390
- assistant_id=tool_name,
391
- input=arguments,
392
- context=context,
321
+ assistant_id=assistant_id,
322
+ input=tool_call.arguments,
323
+ context=tool_call.context,
393
324
  headers=request.headers,
394
325
  raise_error=False,
395
326
  )
327
+ return _tool_result(parse_outcome(value))
328
+
396
329
 
397
- if isinstance(value, dict) and "__error__" in value:
398
- # This is a run-time error in the tool.
399
- return {
400
- "result": {
330
+ async def _assistant_serving(request: ApiRequest, tool_name: str) -> str:
331
+ assistants = await _client().assistants.search(
332
+ limit=MAX_ASSISTANTS, headers=request.headers
333
+ )
334
+ matching = [
335
+ assistant
336
+ for assistant in assistants
337
+ if _sanitizers.normalize_name(assistant["name"]) == tool_name
338
+ ]
339
+ if not matching:
340
+ raise _protocol.ProtocolError(f"Unknown tool: {tool_name}")
341
+ if len(matching) > 1:
342
+ raise _protocol.ProtocolError("Multiple tools found with the same name.")
343
+ return matching[0]["assistant_id"]
344
+
345
+
346
+ def _tool_result(outcome: RunOutcome) -> dict[str, JsonValue]:
347
+ match outcome:
348
+ case Completed(output=output):
349
+ return {"content": [_text_content(json_dumpb(output).decode())]}
350
+ case Failed(message=message):
351
+ return {"isError": True, "content": [_text_content(message)]}
352
+ case Paused(interrupts=interrupts):
353
+ return {
401
354
  "isError": True,
402
355
  "content": [
403
- {"type": "text", "text": value["__error__"]["error"]},
356
+ _text_content(PAUSED_TOOL_MESSAGE),
357
+ _text_content(json_dumpb(interrupts).decode()),
404
358
  ],
405
359
  }
406
- }
360
+ case _:
361
+ assert_never(outcome)
407
362
 
408
- # All good, return the result
409
- return {
410
- "result": {
411
- "content": [
412
- {"type": "text", "text": repr(value)},
413
- ]
414
- }
415
- }
363
+
364
+ def _text_content(text: str) -> dict[str, str]:
365
+ return {"type": "text", "text": text}
@@ -0,0 +1,43 @@
1
+ from dataclasses import dataclass
2
+
3
+ from langgraph_api.api.mcp._constants import RUN_FAILED_MESSAGE
4
+ from langgraph_api.cache import JsonValue
5
+ from langgraph_api.schema import Interrupt
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class Completed:
10
+ output: JsonValue
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class Failed:
15
+ message: str
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class Paused:
20
+ interrupts: tuple[Interrupt, ...]
21
+
22
+
23
+ RunOutcome = Completed | Failed | Paused
24
+
25
+
26
+ def parse_outcome(value: JsonValue) -> RunOutcome:
27
+ match value:
28
+ case {"__error__": error, **rest} if not rest:
29
+ return Failed(_error_message(error))
30
+ case {"__interrupt__": [_, *_] as interrupts}:
31
+ return Paused(tuple(interrupts))
32
+ case _:
33
+ return Completed(value)
34
+
35
+
36
+ def _error_message(error: JsonValue) -> str:
37
+ match error:
38
+ case {"error": str() as name, "message": str() as message} if message:
39
+ return f"{name}: {message}"
40
+ case {"error": str() as name}:
41
+ return name
42
+ case _:
43
+ return RUN_FAILED_MESSAGE