langgraph-api 0.0.24__py3-none-any.whl → 0.0.25__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.

Potentially problematic release.


This version of langgraph-api might be problematic. Click here for more details.

@@ -1,5 +1,10 @@
1
1
  import asyncio
2
+ import importlib
3
+ import importlib.util
4
+ import os
2
5
 
6
+ import structlog
7
+ from starlette.applications import Starlette
3
8
  from starlette.requests import Request
4
9
  from starlette.responses import HTMLResponse, JSONResponse, Response
5
10
  from starlette.routing import BaseRoute, Mount, Route
@@ -11,11 +16,13 @@ from langgraph_api.api.runs import runs_routes
11
16
  from langgraph_api.api.store import store_routes
12
17
  from langgraph_api.api.threads import threads_routes
13
18
  from langgraph_api.auth.middleware import auth_middleware
14
- from langgraph_api.config import MIGRATIONS_PATH
19
+ from langgraph_api.config import HTTP_CONFIG, MIGRATIONS_PATH
15
20
  from langgraph_api.graph import js_bg_tasks
16
21
  from langgraph_api.validation import DOCS_HTML
17
22
  from langgraph_storage.database import connect, healthcheck
18
23
 
24
+ logger = structlog.stdlib.get_logger(__name__)
25
+
19
26
 
20
27
  async def ok(request: Request):
21
28
  check_db = int(request.query_params.get("check_db", "0")) # must be "0" or "1"
@@ -37,19 +44,83 @@ async def docs(request: Request):
37
44
  return HTMLResponse(DOCS_HTML)
38
45
 
39
46
 
40
- routes: list[BaseRoute] = [
47
+ meta_routes: list[BaseRoute] = [
41
48
  Route("/ok", ok, methods=["GET"]),
42
49
  Route("/openapi.json", openapi, methods=["GET"]),
43
50
  Route("/docs", docs, methods=["GET"]),
44
51
  Route("/info", meta_info, methods=["GET"]),
45
52
  Route("/metrics", meta_metrics, methods=["GET"]),
46
- Mount(
47
- "",
48
- middleware=[auth_middleware],
49
- routes=[*assistants_routes, *runs_routes, *threads_routes, *store_routes],
50
- ),
51
53
  ]
52
54
 
55
+ protected_routes: list[BaseRoute] = []
56
+
57
+ if HTTP_CONFIG:
58
+ if not HTTP_CONFIG.get("disable_assistants"):
59
+ protected_routes.extend(assistants_routes)
60
+ if not HTTP_CONFIG.get("disable_runs"):
61
+ protected_routes.extend(runs_routes)
62
+ if not HTTP_CONFIG.get("disable_threads"):
63
+ protected_routes.extend(threads_routes)
64
+ if not HTTP_CONFIG.get("disable_store"):
65
+ protected_routes.extend(store_routes)
66
+ else:
67
+ protected_routes.extend(assistants_routes)
68
+ protected_routes.extend(runs_routes)
69
+ protected_routes.extend(threads_routes)
70
+ protected_routes.extend(store_routes)
71
+
72
+ routes: list[BaseRoute] = []
73
+ user_router = None
74
+
75
+
76
+ def load_custom_app(app_import: str) -> Starlette | None:
77
+ # Expect a string in either "path/to/file.py:my_variable" or "some.module.in:my_variable"
78
+ logger.info(f"Loading custom app from {app_import}")
79
+ path, name = app_import.rsplit(":", 1)
80
+ try:
81
+ if os.path.isfile(path) or path.endswith(".py"):
82
+ # Import from file path using a unique module name.
83
+ spec = importlib.util.spec_from_file_location("user_router_module", path)
84
+ if spec is None or spec.loader is None:
85
+ raise ImportError(f"Cannot load spec from {path}")
86
+ module = importlib.util.module_from_spec(spec)
87
+ spec.loader.exec_module(module)
88
+ else:
89
+ # Import as a normal module.
90
+ module = importlib.import_module(path)
91
+ user_router = getattr(module, name)
92
+ if not isinstance(user_router, Starlette):
93
+ raise TypeError(
94
+ f"Object '{name}' in module '{path}' is not a Starlette or FastAPI application. "
95
+ "Please initialize your app by importing and using the appropriate class: "
96
+ "\nfrom starlette.applications import Starlette\n\napp = Starlette(...)\n\n"
97
+ "or\n\nfrom fastapi import FastAPI\n\napp = FastAPI(...)\n\n"
98
+ )
99
+ except ImportError as e:
100
+ raise ImportError(f"Failed to import app module '{path}'") from e
101
+ except AttributeError as e:
102
+ raise AttributeError(f"App '{name}' not found in module '{path}'") from e
103
+ return user_router
104
+
105
+
106
+ if HTTP_CONFIG:
107
+ if router_import := HTTP_CONFIG.get("app"):
108
+ user_router = load_custom_app(router_import)
109
+ if not HTTP_CONFIG.get("disable_meta"):
110
+ routes.extend(meta_routes)
111
+ if protected_routes:
112
+ routes.append(
113
+ Mount(
114
+ "/",
115
+ middleware=[auth_middleware],
116
+ routes=protected_routes,
117
+ ),
118
+ )
119
+
120
+ else:
121
+ routes.extend(meta_routes)
122
+ routes.append(Mount("/", middleware=[auth_middleware], routes=protected_routes))
123
+
53
124
 
54
125
  if "inmem" in MIGRATIONS_PATH:
55
126
 
@@ -1,4 +1,6 @@
1
+ import copy
1
2
  import logging
3
+ import typing
2
4
  from functools import lru_cache
3
5
 
4
6
  import orjson
@@ -9,6 +11,13 @@ from langgraph_api.validation import openapi
9
11
 
10
12
  logger = logging.getLogger(__name__)
11
13
 
14
+ CUSTOM_OPENAPI_SPEC = None
15
+
16
+
17
+ def set_custom_spec(spec: dict):
18
+ global CUSTOM_OPENAPI_SPEC
19
+ CUSTOM_OPENAPI_SPEC = spec
20
+
12
21
 
13
22
  @lru_cache(maxsize=1)
14
23
  def get_openapi_spec() -> str:
@@ -66,4 +75,240 @@ def get_openapi_spec() -> str:
66
75
  "API documentation will not show authentication requirements. "
67
76
  "Add 'openapi' section to auth section of your `langgraph.json` file to specify security schemes."
68
77
  )
69
- return orjson.dumps(openapi)
78
+ final = openapi
79
+ if CUSTOM_OPENAPI_SPEC:
80
+ final = merge_openapi_specs(openapi, CUSTOM_OPENAPI_SPEC)
81
+ return orjson.dumps(final)
82
+
83
+
84
+ def merge_openapi_specs(spec_a: dict, spec_b: dict) -> dict:
85
+ """
86
+ Merge two OpenAPI specifications with spec_b taking precedence on conflicts.
87
+
88
+ This function handles merging of the following keys:
89
+ - "openapi": Uses spec_b’s version.
90
+ - "info": Merges dictionaries with spec_b taking precedence.
91
+ - "servers": Merges lists with deduplication (by URL and description).
92
+ - "paths": For shared paths, merges HTTP methods:
93
+ - If a method exists in both, spec_b’s definition wins.
94
+ - Otherwise, methods from both are preserved.
95
+ Additionally, merges path-level "parameters" by (name, in).
96
+ - "components": Merges per component type (schemas, responses, etc.).
97
+ - "security" and "tags": Merges lists with deduplication using a key function.
98
+ - "externalDocs" and any additional keys: spec_b wins.
99
+
100
+ Args:
101
+ spec_a (dict): First OpenAPI specification.
102
+ spec_b (dict): Second OpenAPI specification (takes precedence).
103
+
104
+ Returns:
105
+ dict: The merged OpenAPI specification.
106
+
107
+ Raises:
108
+ TypeError: If either input is not a dict.
109
+ ValueError: If a required field (openapi, info, paths) is missing.
110
+ """
111
+ if not isinstance(spec_a, dict) or not isinstance(spec_b, dict):
112
+ raise TypeError("Both specifications must be dictionaries.")
113
+
114
+ required_fields = {"openapi", "info", "paths"}
115
+ for spec in (spec_a, spec_b):
116
+ missing = required_fields - spec.keys()
117
+ if missing:
118
+ raise ValueError(f"Missing required OpenAPI fields: {missing}")
119
+
120
+ merged = copy.deepcopy(spec_a)
121
+
122
+ if "openapi" in spec_b:
123
+ merged["openapi"] = spec_b["openapi"]
124
+
125
+ # Merge "info": Combine dictionaries with spec_b overriding spec_a.
126
+ merged["info"] = {**merged.get("info", {}), **spec_b.get("info", {})}
127
+
128
+ # Merge "servers": Use deduplication based on (url, description).
129
+ merged["servers"] = _merge_lists(
130
+ merged.get("servers", []),
131
+ spec_b.get("servers", []),
132
+ key_func=lambda x: (x.get("url"), x.get("description")),
133
+ )
134
+
135
+ # Merge "paths": Merge individual paths and methods.
136
+ merged["paths"] = _merge_paths(merged.get("paths", {}), spec_b.get("paths", {}))
137
+
138
+ # Merge "components": Merge per component type.
139
+ merged["components"] = _merge_components(
140
+ merged.get("components", {}), spec_b.get("components", {})
141
+ )
142
+
143
+ # Merge "security": Merge lists with deduplication.
144
+ merged["security"] = _merge_lists(
145
+ merged.get("security", []),
146
+ spec_b.get("security", []),
147
+ key_func=lambda x: tuple(sorted(x.items())),
148
+ )
149
+
150
+ # Merge "tags": Deduplicate tags by "name".
151
+ merged["tags"] = _merge_lists(
152
+ merged.get("tags", []), spec_b.get("tags", []), key_func=lambda x: x.get("name")
153
+ )
154
+
155
+ # Merge "externalDocs": Use spec_b if provided.
156
+ if "externalDocs" in spec_b:
157
+ merged["externalDocs"] = spec_b["externalDocs"]
158
+
159
+ # Merge any additional keys not explicitly handled.
160
+ handled_keys = {
161
+ "openapi",
162
+ "info",
163
+ "servers",
164
+ "paths",
165
+ "components",
166
+ "security",
167
+ "tags",
168
+ "externalDocs",
169
+ }
170
+ for key in set(spec_a.keys()).union(spec_b.keys()) - handled_keys:
171
+ merged[key] = spec_b.get(key, spec_a.get(key))
172
+
173
+ return merged
174
+
175
+
176
+ def _merge_lists(list_a: list, list_b: list, key_func) -> list:
177
+ """
178
+ Merge two lists using a key function for deduplication.
179
+ Items from list_b take precedence over items from list_a.
180
+
181
+ Args:
182
+ list_a (list): First list.
183
+ list_b (list): Second list.
184
+ key_func (callable): Function that returns a key used for deduplication.
185
+
186
+ Returns:
187
+ list: Merged list.
188
+ """
189
+ merged_dict = {}
190
+ for item in list_a:
191
+ key = _ensure_hashable(key_func(item))
192
+ if key not in merged_dict:
193
+ merged_dict[key] = item
194
+ for item in list_b:
195
+ key = _ensure_hashable(key_func(item))
196
+ merged_dict[key] = item # spec_b wins
197
+ return list(merged_dict.values())
198
+
199
+
200
+ def _merge_paths(paths_a: dict, paths_b: dict) -> dict:
201
+ """
202
+ Merge OpenAPI paths objects.
203
+
204
+ For each path:
205
+ - If the path exists in both specs, merge HTTP methods:
206
+ - If a method exists in both, use spec_b’s definition.
207
+ - Otherwise, preserve both.
208
+ - Additionally, merge path-level "parameters" if present.
209
+
210
+ Args:
211
+ paths_a (dict): Paths from the first spec.
212
+ paths_b (dict): Paths from the second spec.
213
+
214
+ Returns:
215
+ dict: Merged paths.
216
+ """
217
+ merged_paths = {}
218
+ # Start with all paths from paths_a.
219
+ for path, methods in paths_a.items():
220
+ merged_paths[path] = copy.deepcopy(methods)
221
+
222
+ # Merge or add paths from paths_b.
223
+ for path, methods_b in paths_b.items():
224
+ if path not in merged_paths:
225
+ merged_paths[path] = copy.deepcopy(methods_b)
226
+ else:
227
+ methods_a = merged_paths[path]
228
+ for method, details_b in methods_b.items():
229
+ key = method.lower()
230
+ # If the method is "parameters", merge them.
231
+ if key == "parameters":
232
+ params_a = methods_a.get("parameters", [])
233
+ params_b = details_b if isinstance(details_b, list) else []
234
+ methods_a["parameters"] = _merge_lists(
235
+ params_a,
236
+ params_b,
237
+ key_func=lambda x: (x.get("name"), x.get("in")),
238
+ )
239
+ else:
240
+ # For HTTP methods, spec_b wins if conflict.
241
+ methods_a[key] = copy.deepcopy(details_b)
242
+ merged_paths[path] = methods_a
243
+ return merged_paths
244
+
245
+
246
+ def _merge_components(components_a: dict, components_b: dict) -> dict:
247
+ """
248
+ Merge OpenAPI components objects.
249
+
250
+ For each component type (schemas, responses, parameters, examples, requestBodies,
251
+ headers, securitySchemes, links, callbacks), merge dictionaries with spec_b taking precedence.
252
+
253
+ Args:
254
+ components_a (dict): Components from the first spec.
255
+ components_b (dict): Components from the second spec.
256
+
257
+ Returns:
258
+ dict: Merged components.
259
+ """
260
+ merged_components = {}
261
+ # Define the common component types to merge.
262
+ component_types = {
263
+ "schemas",
264
+ "responses",
265
+ "parameters",
266
+ "examples",
267
+ "requestBodies",
268
+ "headers",
269
+ "securitySchemes",
270
+ "links",
271
+ "callbacks",
272
+ }
273
+
274
+ for comp_type in component_types:
275
+ comp_a = components_a.get(comp_type, {})
276
+ comp_b = components_b.get(comp_type, {})
277
+ merged_components[comp_type] = {**comp_a, **comp_b}
278
+
279
+ # Merge any additional keys in components.
280
+ extra_keys = set(components_a.keys()).union(components_b.keys()) - component_types
281
+ for key in extra_keys:
282
+ merged_components[key] = {
283
+ **components_a.get(key, {}),
284
+ **components_b.get(key, {}),
285
+ }
286
+
287
+ return merged_components
288
+
289
+
290
+ def _ensure_hashable(obj, depth=0, max_depth=3):
291
+ """
292
+ Recursively convert a Python object into a hashable representation up to a maximum depth.
293
+ If the depth limit is reached, return str(obj).
294
+
295
+ - Lists are converted to tuples.
296
+ - Dictionaries are converted to tuples of sorted (key, value) pairs.
297
+ - Other types are returned as-is.
298
+
299
+ Args:
300
+ obj: The object to convert.
301
+ depth (int): Current recursion depth.
302
+ max_depth (int): Maximum recursion depth.
303
+ """
304
+ if depth >= max_depth:
305
+ return str(obj)
306
+ if isinstance(obj, typing.Sequence):
307
+ return tuple(_ensure_hashable(e, depth + 1, max_depth) for e in obj)
308
+ if isinstance(obj, typing.Mapping):
309
+ return tuple(
310
+ sorted(
311
+ (k, _ensure_hashable(v, depth + 1, max_depth)) for k, v in obj.items()
312
+ )
313
+ )
314
+ return obj
@@ -375,6 +375,8 @@ def _solve_fastapi_dependencies(
375
375
 
376
376
  @functools.lru_cache(maxsize=1)
377
377
  def _depends() -> Any:
378
+ if "fastapi" not in sys.modules:
379
+ return None
378
380
  try:
379
381
  from fastapi.params import Depends
380
382
 
langgraph_api/cli.py CHANGED
@@ -4,8 +4,13 @@ import logging
4
4
  import os
5
5
  import pathlib
6
6
  import threading
7
+ import typing
7
8
  from collections.abc import Mapping, Sequence
8
- from typing import Any, TypedDict
9
+
10
+ from typing_extensions import TypedDict
11
+
12
+ if typing.TYPE_CHECKING:
13
+ from langgraph_api.config import HttpConfig
9
14
 
10
15
  logging.basicConfig(level=logging.INFO)
11
16
  logger = logging.getLogger(__name__)
@@ -147,7 +152,8 @@ def run_server(
147
152
  reload_excludes: Sequence[str] | None = None,
148
153
  store: StoreConfig | None = None,
149
154
  auth: AuthConfig | None = None,
150
- **kwargs: Any,
155
+ http: typing.Optional["HttpConfig"] = None,
156
+ **kwargs: typing.Any,
151
157
  ):
152
158
  """Run the LangGraph API server."""
153
159
 
@@ -263,6 +269,7 @@ For production use, please use LangGraph Cloud.
263
269
  LANGSERVE_GRAPHS=json.dumps(graphs) if graphs else None,
264
270
  LANGSMITH_LANGGRAPH_API_VARIANT="local_dev",
265
271
  LANGGRAPH_AUTH=json.dumps(auth) if auth else None,
272
+ LANGGRAPH_HTTP=json.dumps(http) if http else None,
266
273
  # See https://developer.chrome.com/blog/private-network-access-update-2024-03
267
274
  ALLOW_PRIVATE_NETWORK="true",
268
275
  **(env_vars or {}),
langgraph_api/config.py CHANGED
@@ -1,9 +1,41 @@
1
1
  from os import environ, getenv
2
+ from typing import TypedDict
2
3
 
3
4
  import orjson
4
5
  from starlette.config import Config, undefined
5
6
  from starlette.datastructures import CommaSeparatedStrings
6
7
 
8
+ # types
9
+
10
+
11
+ class CorsConfig(TypedDict, total=False):
12
+ allow_origins: list[str]
13
+ allow_methods: list[str]
14
+ allow_headers: list[str]
15
+ allow_credentials: bool
16
+ allow_origin_regex: str
17
+ expose_headers: list[str]
18
+ max_age: int
19
+
20
+
21
+ class HttpConfig(TypedDict, total=False):
22
+ app: str
23
+ """Import path for a custom Starlette/FastAPI app to mount"""
24
+ disable_assistants: bool
25
+ """Disable /assistants routes"""
26
+ disable_threads: bool
27
+ """Disable /threads routes"""
28
+ disable_runs: bool
29
+ """Disable /runs routes"""
30
+ disable_store: bool
31
+ """Disable /store routes"""
32
+ disable_meta: bool
33
+ """Disable /ok, /info, /metrics, and /docs routes"""
34
+ cors: CorsConfig | None
35
+
36
+
37
+ # env
38
+
7
39
  env = Config()
8
40
 
9
41
 
@@ -17,7 +49,6 @@ def _parse_json(json: str | None) -> dict | None:
17
49
 
18
50
 
19
51
  STATS_INTERVAL_SECS = env("STATS_INTERVAL_SECS", cast=int, default=60)
20
- HTTP_CONCURRENCY = env("HTTP_CONCURRENCY", cast=int, default=10)
21
52
 
22
53
  # storage
23
54
 
@@ -36,9 +67,12 @@ ALLOW_PRIVATE_NETWORK = env("ALLOW_PRIVATE_NETWORK", cast=bool, default=False)
36
67
  See https://developer.chrome.com/blog/private-network-access-update-2024-03
37
68
  """
38
69
 
70
+ HTTP_CONFIG: HttpConfig | None = env("LANGGRAPH_HTTP", cast=_parse_json, default=None)
39
71
  CORS_ALLOW_ORIGINS = env("CORS_ALLOW_ORIGINS", cast=CommaSeparatedStrings, default="*")
40
-
41
- CORS_CONFIG = env("CORS_CONFIG", cast=_parse_json, default=None)
72
+ if HTTP_CONFIG and HTTP_CONFIG.get("cors"):
73
+ CORS_CONFIG = HTTP_CONFIG["cors"]
74
+ else:
75
+ CORS_CONFIG: CorsConfig | None = env("CORS_CONFIG", cast=_parse_json, default=None)
42
76
  """
43
77
  {
44
78
  "type": "object",
@@ -336,6 +336,7 @@ async def run_js_process(paths_str: str, watch: bool = False):
336
336
  env={
337
337
  "LANGSERVE_GRAPHS": paths_str,
338
338
  "LANGCHAIN_CALLBACKS_BACKGROUND": "true",
339
+ "NODE_ENV": "development" if watch else "production",
339
340
  "CHOKIDAR_USEPOLLING": "true",
340
341
  **os.environ,
341
342
  },
@@ -296,6 +296,7 @@ async def run_js_process(paths_str: str, watch: bool = False):
296
296
  env={
297
297
  "LANGSERVE_GRAPHS": paths_str,
298
298
  "LANGCHAIN_CALLBACKS_BACKGROUND": "true",
299
+ "NODE_ENV": "development" if watch else "production",
299
300
  "CHOKIDAR_USEPOLLING": "true",
300
301
  **os.environ,
301
302
  },
langgraph_api/server.py CHANGED
@@ -1,5 +1,6 @@
1
1
  # MONKEY PATCH: Patch Starlette to fix an error in the library
2
2
  import langgraph_api.patch # noqa: F401,I001
3
+ import sys
3
4
 
4
5
  # WARNING: Keep the import above before other code runs as it
5
6
  # patches an error in the Starlette library.
@@ -7,13 +8,15 @@ import logging
7
8
 
8
9
  import jsonschema_rs
9
10
  import structlog
11
+ from contextlib import asynccontextmanager
10
12
  from langgraph.errors import EmptyInputError, InvalidUpdateError
11
13
  from starlette.applications import Starlette
12
14
  from starlette.middleware import Middleware
13
15
  from starlette.middleware.cors import CORSMiddleware
16
+ from langgraph_api.api.openapi import set_custom_spec
14
17
 
15
18
  import langgraph_api.config as config
16
- from langgraph_api.api import routes
19
+ from langgraph_api.api import routes, user_router
17
20
  from langgraph_api.errors import (
18
21
  overloaded_error_handler,
19
22
  validation_error_handler,
@@ -22,6 +25,7 @@ from langgraph_api.errors import (
22
25
  from langgraph_api.lifespan import lifespan
23
26
  from langgraph_api.middleware.http_logger import AccessLoggerMiddleware
24
27
  from langgraph_api.middleware.private_network import PrivateNetworkMiddleware
28
+ from langgraph_api.utils import SchemaGenerator
25
29
  from langgraph_license.middleware import LicenseValidationMiddleware
26
30
  from langgraph_storage.retry import OVERLOADED_EXCEPTIONS
27
31
 
@@ -35,33 +39,101 @@ if config.ALLOW_PRIVATE_NETWORK:
35
39
 
36
40
  middleware.extend(
37
41
  [
38
- Middleware(
39
- CORSMiddleware,
40
- allow_origins=config.CORS_ALLOW_ORIGINS,
41
- allow_credentials=True,
42
- allow_methods=["*"],
43
- allow_headers=["*"],
44
- )
45
- if config.CORS_CONFIG is None
46
- else Middleware(
47
- CORSMiddleware,
48
- **config.CORS_CONFIG,
42
+ (
43
+ Middleware(
44
+ CORSMiddleware,
45
+ allow_origins=config.CORS_ALLOW_ORIGINS,
46
+ allow_credentials=True,
47
+ allow_methods=["*"],
48
+ allow_headers=["*"],
49
+ )
50
+ if config.CORS_CONFIG is None
51
+ else Middleware(
52
+ CORSMiddleware,
53
+ **config.CORS_CONFIG,
54
+ )
49
55
  ),
50
56
  Middleware(LicenseValidationMiddleware),
51
57
  Middleware(AccessLoggerMiddleware, logger=logger),
52
58
  ]
53
59
  )
60
+ exception_handlers = {
61
+ ValueError: value_error_handler,
62
+ InvalidUpdateError: value_error_handler,
63
+ EmptyInputError: value_error_handler,
64
+ jsonschema_rs.ValidationError: validation_error_handler,
65
+ } | {exc: overloaded_error_handler for exc in OVERLOADED_EXCEPTIONS}
54
66
 
55
67
 
56
- app = Starlette(
57
- routes=routes,
58
- lifespan=lifespan,
59
- middleware=middleware,
60
- exception_handlers={
61
- ValueError: value_error_handler,
62
- InvalidUpdateError: value_error_handler,
63
- EmptyInputError: value_error_handler,
64
- jsonschema_rs.ValidationError: validation_error_handler,
65
- }
66
- | {exc: overloaded_error_handler for exc in OVERLOADED_EXCEPTIONS},
67
- )
68
+ def update_openapi_spec(app):
69
+ spec = None
70
+ if "fastapi" in sys.modules:
71
+ # It's maybe a fastapi app
72
+ from fastapi import FastAPI
73
+
74
+ if isinstance(user_router, FastAPI):
75
+ spec = app.openapi()
76
+
77
+ if spec is None:
78
+ # How do we add
79
+ schemas = SchemaGenerator(
80
+ {
81
+ "openapi": "3.1.0",
82
+ "info": {"title": "LangGraph Platform", "version": "0.1.0"},
83
+ }
84
+ )
85
+ spec = schemas.get_schema(routes=app.routes)
86
+
87
+ if spec:
88
+ set_custom_spec(spec)
89
+
90
+
91
+ if user_router:
92
+ # Merge routes
93
+ app = user_router
94
+ update_openapi_spec(app)
95
+ for route in routes:
96
+ if route.path in ("/docs", "/openapi.json"):
97
+ # Our handlers for these are inclusive of the custom routes and default API ones
98
+ # Don't let these be shadowed
99
+ app.router.routes.insert(0, route)
100
+ else:
101
+ # Everything else could be shadowed.
102
+ app.router.routes.append(route)
103
+
104
+ # Merge lifespans
105
+ original_lifespan = app.router.lifespan_context
106
+ if app.router.on_startup or app.router.on_shutdown:
107
+ raise ValueError(
108
+ f"Cannot merge lifespans with on_startup or on_shutdown: {app.router.on_startup} {app.router.on_shutdown}"
109
+ )
110
+
111
+ @asynccontextmanager
112
+ async def combined_lifespan(app):
113
+ async with lifespan(app):
114
+ if original_lifespan:
115
+ async with original_lifespan(app):
116
+ yield
117
+ else:
118
+ yield
119
+
120
+ app.router.lifespan_context = combined_lifespan
121
+
122
+ # Merge middleware
123
+ app.user_middleware = (app.user_middleware or []) + middleware
124
+ # Merge exception handlers
125
+ for k, v in exception_handlers.items():
126
+ if k not in app.exception_handlers:
127
+ app.exception_handlers[k] = v
128
+ else:
129
+ logger.debug(f"Overriding exception handler for {k}")
130
+
131
+
132
+ else:
133
+ # It's a regular starlette app
134
+ app = Starlette(
135
+ routes=routes,
136
+ lifespan=lifespan,
137
+ middleware=middleware,
138
+ exception_handlers=exception_handlers,
139
+ )
langgraph_api/utils.py CHANGED
@@ -5,12 +5,17 @@ from contextlib import asynccontextmanager
5
5
  from datetime import datetime
6
6
  from typing import Any, Protocol, TypeAlias, TypeVar
7
7
 
8
+ import structlog
8
9
  from langgraph_sdk import Auth
9
10
  from starlette.authentication import AuthCredentials, BaseUser
10
11
  from starlette.exceptions import HTTPException
12
+ from starlette.schemas import BaseSchemaGenerator
11
13
 
12
14
  from langgraph_api.auth.custom import SimpleUser
13
15
 
16
+ logger = structlog.stdlib.get_logger(__name__)
17
+
18
+
14
19
  T = TypeVar("T")
15
20
  Row: TypeAlias = dict[str, Any]
16
21
  AuthContext = contextvars.ContextVar[Auth.types.BaseAuthContext | None](
@@ -98,3 +103,27 @@ def next_cron_date(schedule: str, base_time: datetime) -> datetime:
98
103
 
99
104
  cron_iter = croniter.croniter(schedule, base_time)
100
105
  return cron_iter.get_next(datetime)
106
+
107
+
108
+ class SchemaGenerator(BaseSchemaGenerator):
109
+ def __init__(self, base_schema: dict[str, Any]) -> None:
110
+ self.base_schema = base_schema
111
+
112
+ def get_schema(self, routes: list) -> dict[str, Any]:
113
+ schema = dict(self.base_schema)
114
+ schema.setdefault("paths", {})
115
+ endpoints_info = self.get_endpoints(routes)
116
+
117
+ for endpoint in endpoints_info:
118
+ try:
119
+ parsed = self.parse_docstring(endpoint.func)
120
+ except AssertionError:
121
+ logger.warning("Could not parse docstrings for route %s", endpoint.path)
122
+ parsed = {}
123
+
124
+ if endpoint.path not in schema["paths"]:
125
+ schema["paths"][endpoint.path] = {}
126
+
127
+ schema["paths"][endpoint.path][endpoint.http_method] = parsed
128
+
129
+ return schema
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: langgraph-api
3
- Version: 0.0.24
3
+ Version: 0.0.25
4
4
  Summary:
5
5
  License: Elastic-2.0
6
6
  Author: Nuno Campos
@@ -15,7 +15,7 @@ Requires-Dist: httpx (>=0.27.0)
15
15
  Requires-Dist: jsonschema-rs (>=0.25.0,<0.26.0)
16
16
  Requires-Dist: langchain-core (>=0.2.38,<0.4.0)
17
17
  Requires-Dist: langgraph (>=0.2.56,<0.3.0)
18
- Requires-Dist: langgraph-checkpoint (>=2.0.7,<3.0)
18
+ Requires-Dist: langgraph-checkpoint (>=2.0.15,<3.0)
19
19
  Requires-Dist: langgraph-sdk (>=0.1.51,<0.2.0)
20
20
  Requires-Dist: langsmith (>=0.1.63,<0.4.0)
21
21
  Requires-Dist: orjson (>=3.10.1)
@@ -1,23 +1,23 @@
1
1
  LICENSE,sha256=ZPwVR73Biwm3sK6vR54djCrhaRiM4cAD2zvOQZV8Xis,3859
2
2
  langgraph_api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- langgraph_api/api/__init__.py,sha256=zAdS_0jgjmCJK6E0VwEuNcNRkaknMXfQ2C_OES5tEI4,2066
3
+ langgraph_api/api/__init__.py,sha256=2RJ2tFsZ_l6PnEd9UOL0LbtNfd0H6QziC3AA-L_OQVQ,4812
4
4
  langgraph_api/api/assistants.py,sha256=9wngelDC9vnSs2vYGTHDSCLf5KNds-6mgP1BWnfoY2M,12865
5
5
  langgraph_api/api/meta.py,sha256=hueasWpTDQ6xYLo9Bzt2jhNH8XQRzreH8FTeFfnRoxQ,2700
6
- langgraph_api/api/openapi.py,sha256=AUxfnD5hlRp7s-0g2hBC5dNSNk3HTwOLeJiF489DT44,2762
6
+ langgraph_api/api/openapi.py,sha256=f9gfmWN2AMKNUpLCpSgZuw_aeOF9jCXPdOtFT5PaTWM,10960
7
7
  langgraph_api/api/runs.py,sha256=wAzPXi_kcYB9BcLBL4FXgkBohWwCPIpe4XERnsnWnsA,16042
8
8
  langgraph_api/api/store.py,sha256=VzAJVOwO0IxosBB7km5TTf2rhlWGyPkVz_LpvbxetVY,5437
9
9
  langgraph_api/api/threads.py,sha256=taU61XPcCEhBPCYPZcMDsgVDwwWUWJs8p-PrXFXWY48,8661
10
10
  langgraph_api/asyncio.py,sha256=2fOlx-cZvuj1gQ867Kw1R_wsBsl9jdHYHcUtK2a-x-U,6264
11
11
  langgraph_api/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- langgraph_api/auth/custom.py,sha256=ZFyR5Yf_n5KJrMxBOy30hA396itQVvU4ywcyPwTE0og,21031
12
+ langgraph_api/auth/custom.py,sha256=oyXz7UONY1YeTJ5IBGRAbFUz_K6krkptNxW8zzjb0uk,21088
13
13
  langgraph_api/auth/langsmith/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
14
  langgraph_api/auth/langsmith/backend.py,sha256=InScaL-HYCnxYEauhxU198gRZV9pJn9SzzBoR9Edn7g,2654
15
15
  langgraph_api/auth/langsmith/client.py,sha256=eKchvAom7hdkUXauD8vHNceBDDUijrFgdTV8bKd7x4Q,3998
16
16
  langgraph_api/auth/middleware.py,sha256=qc7SbaFoeWaqxS1wbjZ2PPQ4iI2p9T0shWL7c6g0ed4,1636
17
17
  langgraph_api/auth/noop.py,sha256=Bk6Nf3p8D_iMVy_OyfPlyiJp_aEwzL-sHrbxoXpCbac,586
18
18
  langgraph_api/auth/studio_user.py,sha256=FzFQRROKDlA9JjtBuwyZvk6Mbwno5M9RVYjDO6FU3F8,186
19
- langgraph_api/cli.py,sha256=r7NJVIdTQ9mQ6_X01tk_I0ktlgn9odH0B8J53oaySz4,12022
20
- langgraph_api/config.py,sha256=mkuhRHjUHA5cjzcSy0nYYS6K28FZ_3_FRl9s0sg_030,4225
19
+ langgraph_api/cli.py,sha256=PKZDWjb1NjGXT1qRN0ogsFw2ANlyq7VvXI6QeTPaFhI,12231
20
+ langgraph_api/config.py,sha256=Alb7vm8t13Y1oM9W6JaplApMwTLL1qVZZuXHG08FtgE,5104
21
21
  langgraph_api/cron_scheduler.py,sha256=MW41-TSGUe5OuXycFTy7Ax7ypxHVAv-0ImLonRT8h8o,2629
22
22
  langgraph_api/errors.py,sha256=Bu_i5drgNTyJcLiyrwVE_6-XrSU50BHf9TDpttki9wQ,1690
23
23
  langgraph_api/graph.py,sha256=FombjYQkqj8jrXJFEVkl3m2UyFcq5nSVNswR2HoRsQY,16385
@@ -31,8 +31,8 @@ langgraph_api/js/errors.py,sha256=Cm1TKWlUCwZReDC5AQ6SgNIVGD27Qov2xcgHyf8-GXo,36
31
31
  langgraph_api/js/global.d.ts,sha256=zR_zLYfpzyPfxpEFth5RgZoyfGulIXyZYPRf7cU0K0Y,106
32
32
  langgraph_api/js/package.json,sha256=AmpkMzr96yF9xZ7bCrSApF-j7PJH6WeALn9HpPGBnmQ,840
33
33
  langgraph_api/js/remote.py,sha256=D9cqcEgXau-fm_trpNwCHMra5BXntgUa469lgs_a9JQ,622
34
- langgraph_api/js/remote_new.py,sha256=T_Vr8459bax1C9xxqz_ZYmGivq5Vhspg2Iu9TL0Qc-Q,22707
35
- langgraph_api/js/remote_old.py,sha256=wC0wpYR4xv6Xqqq1PboIluViVeJ9ETFVTUEl53FSZyo,22578
34
+ langgraph_api/js/remote_new.py,sha256=-9gsJeV32cHPXd-EESFVfPoBoPC7pqR7XKhkb_q9cHA,22781
35
+ langgraph_api/js/remote_old.py,sha256=A28NMSvLGPfg044NDTHSv63pcujYZQXZujDxryGGhOw,22652
36
36
  langgraph_api/js/schema.py,sha256=7idnv7URlYUdSNMBXQcw7E4SxaPxCq_Oxwnlml8q5ik,408
37
37
  langgraph_api/js/server_sent_events.py,sha256=DLgXOHauemt7706vnfDUCG1GI3TidKycSizccdz9KgA,3702
38
38
  langgraph_api/js/src/graph.mts,sha256=J-M-vYHj1G5tyonPUym3ePNGqGYtspPCrZOgr92xKb4,3171
@@ -71,11 +71,11 @@ langgraph_api/queue.py,sha256=2sw9HB2cYVBhYUNA3F7lcJAgRjhQJXhA_HNGhFt2BW8,14508
71
71
  langgraph_api/route.py,sha256=fM4qYCGbmH0a3_cV8uKocb1sLklehxO6HhdRXqLK6OM,4421
72
72
  langgraph_api/schema.py,sha256=mgam5lpuqZnrNWMm_0nQ95683gCnCvQNRKbiuFj7z8Q,5310
73
73
  langgraph_api/serde.py,sha256=VoJ7Z1IuqrQGXFzEP1qijAITtWCrmjtVqlCRuScjXJI,3533
74
- langgraph_api/server.py,sha256=mKJWBuHN5HFHCQIL_FtH04wyFabR4mR6WmQcioI-_Ns,2071
74
+ langgraph_api/server.py,sha256=hrR39cIYZ13nUXWvOKHdhuASSdA4R6JOFu6LJUJ7t7I,4324
75
75
  langgraph_api/sse.py,sha256=2wNodCOP2eg7a9mpSu0S3FQ0CHk2BBV_vv0UtIgJIcc,4034
76
76
  langgraph_api/state.py,sha256=8jx4IoTCOjTJuwzuXJKKFwo1VseHjNnw_CCq4x1SW14,2284
77
77
  langgraph_api/stream.py,sha256=MUYYNgwtLs1Mhq1dm12zda7j8uFYir49umigK6CnuXU,11944
78
- langgraph_api/utils.py,sha256=aIHPp_yu-NFUzs0jzNRm6mcqoZmtbLMZl1ugFmwWTss,2692
78
+ langgraph_api/utils.py,sha256=92mSti9GfGdMRRWyESKQW5yV-75Z9icGHnIrBYvdypU,3619
79
79
  langgraph_api/validation.py,sha256=McizHlz-Ez8Jhdbc79mbPSde7GIuf2Jlbjx2yv_l6dA,4475
80
80
  langgraph_license/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
81
81
  langgraph_license/middleware.py,sha256=_ODIYzQkymr6W9_Fp9wtf1kAQspnpsmr53xuzyF2GA0,612
@@ -90,8 +90,8 @@ langgraph_storage/store.py,sha256=D-p3cWc_umamkKp-6Cz3cAriSACpvM5nxUIvND6PuxE,27
90
90
  langgraph_storage/ttl_dict.py,sha256=FlpEY8EANeXWKo_G5nmIotPquABZGyIJyk6HD9u6vqY,1533
91
91
  logging.json,sha256=3RNjSADZmDq38eHePMm1CbP6qZ71AmpBtLwCmKU9Zgo,379
92
92
  openapi.json,sha256=DqXpD6JD4tvSOhUgDN_6f919F8YmOSAK4CsGi1NDoiI,125252
93
- langgraph_api-0.0.24.dist-info/LICENSE,sha256=ZPwVR73Biwm3sK6vR54djCrhaRiM4cAD2zvOQZV8Xis,3859
94
- langgraph_api-0.0.24.dist-info/METADATA,sha256=o2E2pKvMgkmvWDiUbkLHlrG4_UFTRjr_1zE8gDgCFys,4038
95
- langgraph_api-0.0.24.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
96
- langgraph_api-0.0.24.dist-info/entry_points.txt,sha256=3EYLgj89DfzqJHHYGxPH4A_fEtClvlRbWRUHaXO7hj4,77
97
- langgraph_api-0.0.24.dist-info/RECORD,,
93
+ langgraph_api-0.0.25.dist-info/LICENSE,sha256=ZPwVR73Biwm3sK6vR54djCrhaRiM4cAD2zvOQZV8Xis,3859
94
+ langgraph_api-0.0.25.dist-info/METADATA,sha256=fDPk6UfSu7A2T8IAwOrTP424v7TtZkAdM_L2Q3E9BTk,4039
95
+ langgraph_api-0.0.25.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
96
+ langgraph_api-0.0.25.dist-info/entry_points.txt,sha256=3EYLgj89DfzqJHHYGxPH4A_fEtClvlRbWRUHaXO7hj4,77
97
+ langgraph_api-0.0.25.dist-info/RECORD,,