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.

@@ -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}
@@ -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
- return NoopAuthBackend()
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": {"simple": {"class": "langgraph_api.logging.Formatter"}},
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",