langgraph-api 0.0.8__py3-none-any.whl → 0.0.9__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.
- langgraph_api/api/openapi.py +38 -1
- langgraph_api/api/runs.py +70 -36
- langgraph_api/auth/custom.py +520 -0
- langgraph_api/auth/middleware.py +8 -3
- langgraph_api/cli.py +47 -1
- langgraph_api/config.py +24 -0
- langgraph_api/cron_scheduler.py +32 -27
- langgraph_api/graph.py +0 -11
- langgraph_api/models/run.py +77 -19
- langgraph_api/route.py +2 -0
- langgraph_api/utils.py +32 -0
- {langgraph_api-0.0.8.dist-info → langgraph_api-0.0.9.dist-info}/METADATA +2 -2
- {langgraph_api-0.0.8.dist-info → langgraph_api-0.0.9.dist-info}/RECORD +19 -18
- langgraph_storage/checkpoint.py +16 -0
- langgraph_storage/database.py +17 -1
- langgraph_storage/ops.py +495 -69
- {langgraph_api-0.0.8.dist-info → langgraph_api-0.0.9.dist-info}/LICENSE +0 -0
- {langgraph_api-0.0.8.dist-info → langgraph_api-0.0.9.dist-info}/WHEEL +0 -0
- {langgraph_api-0.0.8.dist-info → langgraph_api-0.0.9.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import copy
|
|
3
|
+
import functools
|
|
4
|
+
import importlib.util
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
10
|
+
from contextlib import AsyncExitStack
|
|
11
|
+
from typing import Any, get_args
|
|
12
|
+
|
|
13
|
+
from langgraph_sdk import Auth
|
|
14
|
+
from starlette.authentication import (
|
|
15
|
+
AuthCredentials,
|
|
16
|
+
AuthenticationBackend,
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
BaseUser,
|
|
19
|
+
SimpleUser,
|
|
20
|
+
)
|
|
21
|
+
from starlette.concurrency import run_in_threadpool
|
|
22
|
+
from starlette.exceptions import HTTPException
|
|
23
|
+
from starlette.requests import HTTPConnection, Request
|
|
24
|
+
from starlette.responses import Response
|
|
25
|
+
|
|
26
|
+
from langgraph_api.auth.langsmith.backend import LangsmithAuthBackend
|
|
27
|
+
from langgraph_api.config import LANGGRAPH_AUTH
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
SUPPORTED_PARAMETERS = {
|
|
32
|
+
"request": Request,
|
|
33
|
+
"body": dict,
|
|
34
|
+
"user": BaseUser,
|
|
35
|
+
"path": str,
|
|
36
|
+
"method": str,
|
|
37
|
+
"scopes": list[str],
|
|
38
|
+
"path_params": dict[str, str] | None,
|
|
39
|
+
"query_params": dict[str, str] | None,
|
|
40
|
+
"headers": dict[str, bytes] | None,
|
|
41
|
+
"authorization": str | None,
|
|
42
|
+
"scope": dict[str, Any],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_custom_auth_middleware() -> AuthenticationBackend:
|
|
47
|
+
"""Load authentication function from a Python file or config dict."""
|
|
48
|
+
if not LANGGRAPH_AUTH:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"LANGGRAPH_AUTH must be set to a Python file path or a config dict"
|
|
51
|
+
" to use custom authentication."
|
|
52
|
+
)
|
|
53
|
+
return _get_custom_auth_middleware(LANGGRAPH_AUTH)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@functools.lru_cache(maxsize=1)
|
|
57
|
+
def get_auth_instance() -> Auth | None:
|
|
58
|
+
if not LANGGRAPH_AUTH:
|
|
59
|
+
return None
|
|
60
|
+
path = LANGGRAPH_AUTH.get("path")
|
|
61
|
+
if path is None:
|
|
62
|
+
return None
|
|
63
|
+
return _get_auth_instance(path)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def handle_event(
|
|
67
|
+
ctx: Auth.types.AuthContext | None,
|
|
68
|
+
value: dict,
|
|
69
|
+
) -> Auth.types.FilterType | None:
|
|
70
|
+
"""Run all handlers for a request.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
- A FilteredValue with the modified value and any filters to apply
|
|
74
|
+
- Raises HTTPException(403) if any handler rejects the request
|
|
75
|
+
|
|
76
|
+
Handlers are run in order from most specific to most general:
|
|
77
|
+
1. Resource+action specific handlers (e.g. "runs", "create")
|
|
78
|
+
2. Resource handlers (e.g. "runs", "*")
|
|
79
|
+
3. Action handlers (e.g. "*", "create")
|
|
80
|
+
4. Global handlers ("*", "*")
|
|
81
|
+
"""
|
|
82
|
+
if ctx is None:
|
|
83
|
+
return
|
|
84
|
+
auth = get_auth_instance()
|
|
85
|
+
if auth is None:
|
|
86
|
+
return
|
|
87
|
+
handler = _get_handler(auth, ctx)
|
|
88
|
+
if not handler:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
result = await handler(ctx=ctx, value=value)
|
|
92
|
+
|
|
93
|
+
if result in (None, True):
|
|
94
|
+
return
|
|
95
|
+
if result is True:
|
|
96
|
+
raise HTTPException(403, "Forbidden")
|
|
97
|
+
|
|
98
|
+
if not isinstance(result, dict):
|
|
99
|
+
raise HTTPException(
|
|
100
|
+
500,
|
|
101
|
+
f"Auth handler returned invalid result. Expected filter dict, None, or boolean. Got {type(result)} instead.",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class CustomAuthBackend(AuthenticationBackend):
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
fn: (
|
|
111
|
+
Callable[
|
|
112
|
+
[Request],
|
|
113
|
+
Awaitable[tuple[list[str], Any]],
|
|
114
|
+
]
|
|
115
|
+
| None
|
|
116
|
+
) = None,
|
|
117
|
+
disable_studio_auth: bool = False,
|
|
118
|
+
):
|
|
119
|
+
assert fn is not None
|
|
120
|
+
if fn is None:
|
|
121
|
+
self.fn = None
|
|
122
|
+
elif not inspect.iscoroutinefunction(fn):
|
|
123
|
+
self.fn = functools.partial(run_in_threadpool, fn)
|
|
124
|
+
else:
|
|
125
|
+
self.fn = fn
|
|
126
|
+
self._param_names = (
|
|
127
|
+
get_named_arguments(fn, supported_params=SUPPORTED_PARAMETERS)
|
|
128
|
+
if fn
|
|
129
|
+
else None
|
|
130
|
+
)
|
|
131
|
+
if not disable_studio_auth:
|
|
132
|
+
self.ls_auth = LangsmithAuthBackend()
|
|
133
|
+
else:
|
|
134
|
+
self.ls_auth = None
|
|
135
|
+
|
|
136
|
+
async def authenticate(
|
|
137
|
+
self, conn: HTTPConnection
|
|
138
|
+
) -> tuple[AuthCredentials, BaseUser] | None:
|
|
139
|
+
if self.ls_auth is not None and (
|
|
140
|
+
(auth_scheme := conn.headers.get("x-auth-scheme"))
|
|
141
|
+
and auth_scheme == "langsmith"
|
|
142
|
+
):
|
|
143
|
+
return await self.ls_auth.authenticate(conn)
|
|
144
|
+
if self.fn is None:
|
|
145
|
+
return None
|
|
146
|
+
try:
|
|
147
|
+
args = _extract_arguments_from_scope(
|
|
148
|
+
conn.scope, self._param_names, request=Request(conn.scope)
|
|
149
|
+
)
|
|
150
|
+
scopes, user = await self.fn(**args)
|
|
151
|
+
return AuthCredentials(scopes), _normalize_user(user)
|
|
152
|
+
except AssertionError as e:
|
|
153
|
+
raise AuthenticationError(str(e)) from None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _get_custom_auth_middleware(
|
|
157
|
+
config: str | dict,
|
|
158
|
+
) -> AuthenticationBackend:
|
|
159
|
+
disable_studio_auth = False
|
|
160
|
+
if isinstance(config, str):
|
|
161
|
+
path: str | None = config
|
|
162
|
+
else:
|
|
163
|
+
path = config.get("path")
|
|
164
|
+
disable_studio_auth = config.get("disable_studio_auth", disable_studio_auth)
|
|
165
|
+
auth_instance = _get_auth_instance(path)
|
|
166
|
+
return CustomAuthBackend(
|
|
167
|
+
auth_instance._authenticate_handler if auth_instance else None,
|
|
168
|
+
disable_studio_auth,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@functools.lru_cache(maxsize=1)
|
|
173
|
+
def _get_auth_instance(path: str | None = None) -> Auth | None:
|
|
174
|
+
if path is not None:
|
|
175
|
+
auth_instance = _load_auth_obj(path)
|
|
176
|
+
else:
|
|
177
|
+
auth_instance = None
|
|
178
|
+
|
|
179
|
+
if auth_instance is not None and (
|
|
180
|
+
deps := _get_dependencies(auth_instance._authenticate_handler)
|
|
181
|
+
):
|
|
182
|
+
auth_instance._authenticate_handler = _solve_fastapi_dependencies(
|
|
183
|
+
auth_instance._authenticate_handler, deps
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return auth_instance
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _extract_arguments_from_scope(
|
|
190
|
+
scope: dict[str, Any],
|
|
191
|
+
param_names: set[str],
|
|
192
|
+
request: Request | None = None,
|
|
193
|
+
response: Response | None = None,
|
|
194
|
+
) -> dict[str, Any]:
|
|
195
|
+
"""Extract requested arguments from the ASGI scope (and request/response if needed)."""
|
|
196
|
+
|
|
197
|
+
auth = scope.get("auth")
|
|
198
|
+
args: dict[str, Any] = {}
|
|
199
|
+
if "scope" in param_names:
|
|
200
|
+
args["scope"] = scope
|
|
201
|
+
if "request" in param_names and request is not None:
|
|
202
|
+
args["request"] = request
|
|
203
|
+
if "response" in param_names and response is not None:
|
|
204
|
+
args["response"] = response
|
|
205
|
+
if "user" in param_names:
|
|
206
|
+
user = scope.get("user")
|
|
207
|
+
args["user"] = user
|
|
208
|
+
if "scopes" in param_names:
|
|
209
|
+
args["scopes"] = auth.scopes if auth else []
|
|
210
|
+
if "path_params" in param_names:
|
|
211
|
+
args["path_params"] = scope.get("path_params", {})
|
|
212
|
+
if "path" in param_names:
|
|
213
|
+
args["path"] = scope["path"]
|
|
214
|
+
if "query_params" in param_names:
|
|
215
|
+
args["query_params"] = scope.get("query_params", {})
|
|
216
|
+
if "headers" in param_names:
|
|
217
|
+
args["headers"] = dict(scope.get("headers", {}))
|
|
218
|
+
if "authorization" in param_names:
|
|
219
|
+
headers = scope.get("headers", {})
|
|
220
|
+
args["authorization"] = headers.get("authorization") or headers.get(
|
|
221
|
+
"Authorization"
|
|
222
|
+
)
|
|
223
|
+
if "method" in param_names:
|
|
224
|
+
args["method"] = scope.get("method")
|
|
225
|
+
|
|
226
|
+
return args
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _get_dependencies(fn: Callable | None) -> dict[str, Any] | None:
|
|
230
|
+
if fn is None:
|
|
231
|
+
return None
|
|
232
|
+
Depends = _depends()
|
|
233
|
+
if Depends is None:
|
|
234
|
+
# FastAPI not installed
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
# For Python versions < 3.10, get_annotations is available via inspect
|
|
238
|
+
# For Python 3.10+, it's built-in. Here we just call it.
|
|
239
|
+
annotations = (
|
|
240
|
+
inspect.get_annotations(fn)
|
|
241
|
+
if hasattr(inspect, "get_annotations")
|
|
242
|
+
else getattr(fn, "__annotations__", {})
|
|
243
|
+
)
|
|
244
|
+
deps = {}
|
|
245
|
+
for arg_name, arg_type in annotations.items():
|
|
246
|
+
for annotation in get_args(arg_type):
|
|
247
|
+
if isinstance(annotation, Depends):
|
|
248
|
+
deps[arg_name] = annotation
|
|
249
|
+
break
|
|
250
|
+
return deps
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _solve_fastapi_dependencies(
|
|
254
|
+
fn: Callable[..., Any], deps: Mapping[str, Any]
|
|
255
|
+
) -> Callable:
|
|
256
|
+
"""Solve FastAPI dependencies for a given function."""
|
|
257
|
+
from fastapi.dependencies.utils import (
|
|
258
|
+
get_parameterless_sub_dependant,
|
|
259
|
+
solve_dependencies,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
dependents = {
|
|
263
|
+
name: get_parameterless_sub_dependant(depends=dep, path="")
|
|
264
|
+
for name, dep in deps.items()
|
|
265
|
+
}
|
|
266
|
+
for name, dependent in dependents.items():
|
|
267
|
+
if dependent.call is None:
|
|
268
|
+
raise ValueError(
|
|
269
|
+
f"FastAPI-defined dependencies must have a callable dependency. No dependency found for {name} in {fn}."
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
is_async = inspect.iscoroutinefunction(fn)
|
|
273
|
+
|
|
274
|
+
_param_names = {
|
|
275
|
+
k
|
|
276
|
+
for k in get_named_arguments(
|
|
277
|
+
fn, supported_params=SUPPORTED_PARAMETERS | dict(deps)
|
|
278
|
+
)
|
|
279
|
+
if k not in dependents
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async def decorator(scope: dict, request: Request):
|
|
283
|
+
async with AsyncExitStack() as stack:
|
|
284
|
+
all_solved = await asyncio.gather(
|
|
285
|
+
*(
|
|
286
|
+
solve_dependencies(
|
|
287
|
+
request=request,
|
|
288
|
+
dependant=dependent,
|
|
289
|
+
async_exit_stack=stack,
|
|
290
|
+
embed_body_fields=False,
|
|
291
|
+
)
|
|
292
|
+
for dependent in dependents.values()
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
all_injected = await asyncio.gather(
|
|
296
|
+
*(
|
|
297
|
+
_run_async(dependent.call, solved.values, is_async)
|
|
298
|
+
for dependent, solved in zip(
|
|
299
|
+
dependents.values(), all_solved, strict=False
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
kwargs = {
|
|
304
|
+
name: value
|
|
305
|
+
for name, value in zip(dependents.keys(), all_injected, strict=False)
|
|
306
|
+
}
|
|
307
|
+
other_params = _extract_arguments_from_scope(
|
|
308
|
+
scope, _param_names, request=request
|
|
309
|
+
)
|
|
310
|
+
return await fn(**(kwargs | other_params))
|
|
311
|
+
|
|
312
|
+
return decorator
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@functools.lru_cache(maxsize=1)
|
|
316
|
+
def _depends() -> Any:
|
|
317
|
+
try:
|
|
318
|
+
from fastapi.params import Depends
|
|
319
|
+
|
|
320
|
+
return Depends
|
|
321
|
+
except ImportError:
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class DotDict:
|
|
326
|
+
def __init__(self, dictionary: dict[str, Any]):
|
|
327
|
+
self._dict = dictionary
|
|
328
|
+
for key, value in dictionary.items():
|
|
329
|
+
if isinstance(value, dict):
|
|
330
|
+
setattr(self, key, DotDict(value))
|
|
331
|
+
else:
|
|
332
|
+
setattr(self, key, value)
|
|
333
|
+
|
|
334
|
+
def __getattr__(self, name):
|
|
335
|
+
if name not in self._dict:
|
|
336
|
+
raise AttributeError(f"'DotDict' object has no attribute '{name}'")
|
|
337
|
+
return self._dict[name]
|
|
338
|
+
|
|
339
|
+
def __deepcopy__(self, memo):
|
|
340
|
+
return DotDict(copy.deepcopy(self._dict))
|
|
341
|
+
|
|
342
|
+
def dict(self):
|
|
343
|
+
return self._dict
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class ProxyUser(BaseUser):
|
|
347
|
+
"""A proxy that wraps a user object to ensure it has all BaseUser properties.
|
|
348
|
+
|
|
349
|
+
This will:
|
|
350
|
+
1. Ensure the required identity property exists
|
|
351
|
+
2. Provide defaults for optional properties if they don't exist
|
|
352
|
+
3. Proxy all other attributes to the underlying user object
|
|
353
|
+
"""
|
|
354
|
+
|
|
355
|
+
def __init__(self, user: Any):
|
|
356
|
+
if not hasattr(user, "identity"):
|
|
357
|
+
raise ValueError("User must have an identity property")
|
|
358
|
+
self._user = user
|
|
359
|
+
|
|
360
|
+
@property
|
|
361
|
+
def identity(self) -> str:
|
|
362
|
+
return self._user.identity
|
|
363
|
+
|
|
364
|
+
@property
|
|
365
|
+
def is_authenticated(self) -> bool:
|
|
366
|
+
return getattr(self._user, "is_authenticated", True)
|
|
367
|
+
|
|
368
|
+
@property
|
|
369
|
+
def display_name(self) -> str:
|
|
370
|
+
return getattr(self._user, "display_name", self.identity)
|
|
371
|
+
|
|
372
|
+
def __deepcopy__(self, memo):
|
|
373
|
+
return ProxyUser(copy.deepcopy(self._user))
|
|
374
|
+
|
|
375
|
+
def model_dump(self):
|
|
376
|
+
if hasattr(self._user, "model_dump") and callable(self._user.model_dump):
|
|
377
|
+
return {
|
|
378
|
+
"identity": self.identity,
|
|
379
|
+
"is_authenticated": self.is_authenticated,
|
|
380
|
+
"display_name": self.display_name,
|
|
381
|
+
**self._user.model_dump(mode="json"),
|
|
382
|
+
}
|
|
383
|
+
return self.dict()
|
|
384
|
+
|
|
385
|
+
def dict(self):
|
|
386
|
+
d = (
|
|
387
|
+
self._user.dict()
|
|
388
|
+
if hasattr(self._user, "dict") and callable(self._user.dict)
|
|
389
|
+
else {}
|
|
390
|
+
)
|
|
391
|
+
return {
|
|
392
|
+
"identity": self.identity,
|
|
393
|
+
"is_authenticated": self.is_authenticated,
|
|
394
|
+
"display_name": self.display_name,
|
|
395
|
+
**d,
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
def __getattr__(self, name: str) -> Any:
|
|
399
|
+
"""Proxy any other attributes to the underlying user object."""
|
|
400
|
+
return getattr(self._user, name)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _normalize_user(user: Any) -> BaseUser:
|
|
404
|
+
"""Normalize user into a BaseUser instance."""
|
|
405
|
+
if isinstance(user, BaseUser):
|
|
406
|
+
return user
|
|
407
|
+
if hasattr(user, "identity"):
|
|
408
|
+
return ProxyUser(user)
|
|
409
|
+
if isinstance(user, str):
|
|
410
|
+
return SimpleUser(username=user)
|
|
411
|
+
if isinstance(user, dict) and "identity" in user:
|
|
412
|
+
return ProxyUser(DotDict(user))
|
|
413
|
+
raise ValueError(
|
|
414
|
+
f"Expected a BaseUser instance with required property: identity (str). "
|
|
415
|
+
f"Optional properties are: is_authenticated (bool, defaults to True) and "
|
|
416
|
+
f"display_name (str, defaults to identity). Got {type(user)} instead"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _load_auth_obj(path: str) -> Auth:
|
|
421
|
+
"""Load an object from a path string."""
|
|
422
|
+
if ":" not in path:
|
|
423
|
+
raise ValueError(
|
|
424
|
+
f"Invalid auth path format: {path}. "
|
|
425
|
+
"Must be in format: './path/to/file.py:name' or 'module:name'"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
module_name, callable_name = path.rsplit(":", 1)
|
|
429
|
+
module_name = module_name.rstrip(":")
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
if "/" in module_name or ".py" in module_name:
|
|
433
|
+
# Load from file path
|
|
434
|
+
modname = f"dynamic_module_{hash(module_name)}"
|
|
435
|
+
modspec = importlib.util.spec_from_file_location(modname, module_name)
|
|
436
|
+
if modspec is None or modspec.loader is None:
|
|
437
|
+
raise ValueError(f"Could not load file: {module_name}")
|
|
438
|
+
module = importlib.util.module_from_spec(modspec)
|
|
439
|
+
sys.modules[modname] = module
|
|
440
|
+
modspec.loader.exec_module(module)
|
|
441
|
+
else:
|
|
442
|
+
# Load from Python module
|
|
443
|
+
module = importlib.import_module(module_name)
|
|
444
|
+
|
|
445
|
+
loaded_auth = getattr(module, callable_name, None)
|
|
446
|
+
if loaded_auth is None:
|
|
447
|
+
raise ValueError(
|
|
448
|
+
f"Could not find auth '{callable_name}' in module: {module_name}"
|
|
449
|
+
)
|
|
450
|
+
if not isinstance(loaded_auth, Auth):
|
|
451
|
+
raise ValueError(f"Expected an Auth instance, got {type(loaded_auth)}")
|
|
452
|
+
return loaded_auth
|
|
453
|
+
|
|
454
|
+
except ImportError as e:
|
|
455
|
+
e.add_note(f"Could not import module:\n{module_name}\n\n")
|
|
456
|
+
if os.environ.get("LANGSMITH_LANGGRAPH_API_VARIANT") == "local_dev":
|
|
457
|
+
e.add_note(
|
|
458
|
+
"If you're in development mode, make sure you've installed your project "
|
|
459
|
+
"and its dependencies:\n"
|
|
460
|
+
"- For requirements.txt: pip install -r requirements.txt\n"
|
|
461
|
+
"- For pyproject.toml: pip install -e .\n"
|
|
462
|
+
)
|
|
463
|
+
raise
|
|
464
|
+
except FileNotFoundError as e:
|
|
465
|
+
raise ValueError(f"Could not find file: {module_name}") from e
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
async def _run_async(
|
|
469
|
+
dep_call: Callable[..., Any], values: dict[str, Any], is_coroutine: bool
|
|
470
|
+
) -> Any:
|
|
471
|
+
"""Run a dependency call either in threadpool or directly if async."""
|
|
472
|
+
if is_coroutine:
|
|
473
|
+
return await dep_call(**values)
|
|
474
|
+
return await run_in_threadpool(dep_call, **values)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _get_handler(auth: Auth, ctx: Auth.types.AuthContext) -> Auth.types.Handler | None:
|
|
478
|
+
"""Get the most specific handler for a resource and action."""
|
|
479
|
+
key = (ctx.resource, ctx.action)
|
|
480
|
+
if key in auth._handler_cache:
|
|
481
|
+
return auth._handler_cache[key]
|
|
482
|
+
keys = [
|
|
483
|
+
(ctx.resource, ctx.action), # most specific
|
|
484
|
+
(ctx.resource, "*"), # resource-specific
|
|
485
|
+
("*", ctx.action), # action-specific
|
|
486
|
+
("*", "*"), # most general
|
|
487
|
+
]
|
|
488
|
+
for key in keys:
|
|
489
|
+
if key in auth._handlers:
|
|
490
|
+
result = auth._handlers[key][
|
|
491
|
+
-1
|
|
492
|
+
] # Get the last defined, most specific handler
|
|
493
|
+
auth._handler_cache[key] = result
|
|
494
|
+
return result
|
|
495
|
+
if auth._global_handlers:
|
|
496
|
+
return auth._global_handlers[-1]
|
|
497
|
+
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def get_named_arguments(fn: Callable, supported_params: dict) -> set[str]:
|
|
502
|
+
"""Get the named arguments that a function accepts, ensuring they're supported."""
|
|
503
|
+
sig = inspect.signature(fn)
|
|
504
|
+
# Check for unsupported required parameters
|
|
505
|
+
unsupported = []
|
|
506
|
+
for name, param in sig.parameters.items():
|
|
507
|
+
if name not in supported_params and param.default is param.empty:
|
|
508
|
+
unsupported.append(name)
|
|
509
|
+
|
|
510
|
+
if unsupported:
|
|
511
|
+
supported_str = "\n".join(
|
|
512
|
+
f" - {name} ({getattr(typ, '__name__', str(typ))})"
|
|
513
|
+
for name, typ in supported_params.items()
|
|
514
|
+
)
|
|
515
|
+
raise ValueError(
|
|
516
|
+
f"Handler has unsupported required parameters: {', '.join(unsupported)}.\n"
|
|
517
|
+
f"Supported parameters are:\n{supported_str}"
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
return {p for p in sig.parameters if p in supported_params}
|
langgraph_api/auth/middleware.py
CHANGED
|
@@ -15,10 +15,15 @@ def get_auth_backend():
|
|
|
15
15
|
from langgraph_api.auth.langsmith.backend import LangsmithAuthBackend
|
|
16
16
|
|
|
17
17
|
return LangsmithAuthBackend()
|
|
18
|
-
else:
|
|
19
|
-
from langgraph_api.auth.noop import NoopAuthBackend
|
|
20
18
|
|
|
21
|
-
|
|
19
|
+
if LANGGRAPH_AUTH_TYPE == "custom":
|
|
20
|
+
from langgraph_api.auth.custom import get_custom_auth_middleware
|
|
21
|
+
|
|
22
|
+
return get_custom_auth_middleware()
|
|
23
|
+
|
|
24
|
+
from langgraph_api.auth.noop import NoopAuthBackend
|
|
25
|
+
|
|
26
|
+
return NoopAuthBackend()
|
|
22
27
|
|
|
23
28
|
|
|
24
29
|
def on_error(conn: HTTPConnection, exc: AuthenticationError):
|
langgraph_api/cli.py
CHANGED
|
@@ -94,6 +94,44 @@ class StoreConfig(TypedDict, total=False):
|
|
|
94
94
|
index: IndexConfig
|
|
95
95
|
|
|
96
96
|
|
|
97
|
+
class SecurityConfig(TypedDict, total=False):
|
|
98
|
+
securitySchemes: dict
|
|
99
|
+
security: list
|
|
100
|
+
# path => {method => security}
|
|
101
|
+
paths: dict[str, dict[str, list]]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class AuthConfig(TypedDict, total=False):
|
|
105
|
+
path: str
|
|
106
|
+
"""Path to the authentication function in a Python file."""
|
|
107
|
+
disable_studio_auth: bool
|
|
108
|
+
"""Whether to disable auth when connecting from the LangSmith Studio."""
|
|
109
|
+
openapi: SecurityConfig
|
|
110
|
+
"""The schema to use for updating the openapi spec.
|
|
111
|
+
|
|
112
|
+
Example:
|
|
113
|
+
{
|
|
114
|
+
"securitySchemes": {
|
|
115
|
+
"OAuth2": {
|
|
116
|
+
"type": "oauth2",
|
|
117
|
+
"flows": {
|
|
118
|
+
"password": {
|
|
119
|
+
"tokenUrl": "/token",
|
|
120
|
+
"scopes": {
|
|
121
|
+
"me": "Read information about the current user",
|
|
122
|
+
"items": "Access to create and manage items"
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
"security": [
|
|
129
|
+
{"OAuth2": ["me"]} # Default security requirement for all endpoints
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
|
|
97
135
|
def run_server(
|
|
98
136
|
host: str = "127.0.0.1",
|
|
99
137
|
port: int = 2024,
|
|
@@ -108,6 +146,7 @@ def run_server(
|
|
|
108
146
|
reload_includes: Sequence[str] | None = None,
|
|
109
147
|
reload_excludes: Sequence[str] | None = None,
|
|
110
148
|
store: StoreConfig | None = None,
|
|
149
|
+
auth: AuthConfig | None = None,
|
|
111
150
|
**kwargs: Any,
|
|
112
151
|
):
|
|
113
152
|
"""Run the LangGraph API server."""
|
|
@@ -216,6 +255,7 @@ For production use, please use LangGraph Cloud.
|
|
|
216
255
|
LANGGRAPH_STORE=json.dumps(store) if store else None,
|
|
217
256
|
LANGSERVE_GRAPHS=json.dumps(graphs) if graphs else None,
|
|
218
257
|
LANGSMITH_LANGGRAPH_API_VARIANT="local_dev",
|
|
258
|
+
LANGGRAPH_AUTH=json.dumps(auth) if auth else None,
|
|
219
259
|
**(env_vars or {}),
|
|
220
260
|
):
|
|
221
261
|
if open_browser:
|
|
@@ -239,7 +279,11 @@ For production use, please use LangGraph Cloud.
|
|
|
239
279
|
"version": 1,
|
|
240
280
|
"incremental": False,
|
|
241
281
|
"disable_existing_loggers": False,
|
|
242
|
-
"formatters": {
|
|
282
|
+
"formatters": {
|
|
283
|
+
"simple": {
|
|
284
|
+
"class": "langgraph_api.logging.Formatter",
|
|
285
|
+
}
|
|
286
|
+
},
|
|
243
287
|
"handlers": {
|
|
244
288
|
"console": {
|
|
245
289
|
"class": "logging.StreamHandler",
|
|
@@ -292,6 +336,7 @@ def main():
|
|
|
292
336
|
config_data = json.load(f)
|
|
293
337
|
|
|
294
338
|
graphs = config_data.get("graphs", {})
|
|
339
|
+
auth = config_data.get("auth")
|
|
295
340
|
run_server(
|
|
296
341
|
args.host,
|
|
297
342
|
args.port,
|
|
@@ -302,6 +347,7 @@ def main():
|
|
|
302
347
|
debug_port=args.debug_port,
|
|
303
348
|
wait_for_client=args.wait_for_client,
|
|
304
349
|
env=config_data.get("env", None),
|
|
350
|
+
auth=auth,
|
|
305
351
|
)
|
|
306
352
|
|
|
307
353
|
|
langgraph_api/config.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from os import environ, getenv
|
|
2
2
|
|
|
3
|
+
import orjson
|
|
3
4
|
from starlette.config import Config, undefined
|
|
4
5
|
from starlette.datastructures import CommaSeparatedStrings
|
|
5
6
|
|
|
@@ -32,12 +33,35 @@ FF_CRONS_ENABLED = env("FF_CRONS_ENABLED", cast=bool, default=True)
|
|
|
32
33
|
|
|
33
34
|
LANGGRAPH_AUTH_TYPE = env("LANGGRAPH_AUTH_TYPE", cast=str, default="noop")
|
|
34
35
|
|
|
36
|
+
|
|
37
|
+
def _parse_auth(auth: str | None) -> dict | None:
|
|
38
|
+
if not auth:
|
|
39
|
+
return None
|
|
40
|
+
parsed = orjson.loads(auth)
|
|
41
|
+
if not parsed:
|
|
42
|
+
return None
|
|
43
|
+
return parsed
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
LANGGRAPH_AUTH = env("LANGGRAPH_AUTH", cast=_parse_auth, default=None)
|
|
47
|
+
LANGSMITH_TENANT_ID = env("LANGSMITH_TENANT_ID", cast=str, default=None)
|
|
48
|
+
LANGSMITH_AUTH_VERIFY_TENANT_ID = env(
|
|
49
|
+
"LANGSMITH_AUTH_VERIFY_TENANT_ID",
|
|
50
|
+
cast=bool,
|
|
51
|
+
default=LANGSMITH_TENANT_ID is not None,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if LANGGRAPH_AUTH:
|
|
55
|
+
LANGGRAPH_AUTH_TYPE = "custom"
|
|
56
|
+
|
|
57
|
+
|
|
35
58
|
if LANGGRAPH_AUTH_TYPE == "langsmith":
|
|
36
59
|
LANGSMITH_AUTH_ENDPOINT = env("LANGSMITH_AUTH_ENDPOINT", cast=str)
|
|
37
60
|
LANGSMITH_TENANT_ID = env("LANGSMITH_TENANT_ID", cast=str)
|
|
38
61
|
LANGSMITH_AUTH_VERIFY_TENANT_ID = env(
|
|
39
62
|
"LANGSMITH_AUTH_VERIFY_TENANT_ID", cast=bool, default=True
|
|
40
63
|
)
|
|
64
|
+
|
|
41
65
|
else:
|
|
42
66
|
LANGSMITH_AUTH_ENDPOINT = env(
|
|
43
67
|
"LANGSMITH_AUTH_ENDPOINT",
|