initial commit

This commit is contained in:
2026-05-11 12:36:20 +05:30
commit 384cbe8019
15377 changed files with 2360544 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
from langgraph_sdk.auth import Auth
from langgraph_sdk.client import get_client, get_sync_client
from langgraph_sdk.encryption import Encryption
from langgraph_sdk.encryption.types import EncryptionContext
__version__ = "0.3.9"
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]

View File

@@ -0,0 +1,20 @@
"""Async client exports."""
from langgraph_sdk._async.assistants import AssistantsClient
from langgraph_sdk._async.client import LangGraphClient, get_client
from langgraph_sdk._async.cron import CronClient
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.runs import RunsClient
from langgraph_sdk._async.store import StoreClient
from langgraph_sdk._async.threads import ThreadsClient
__all__ = [
"AssistantsClient",
"CronClient",
"HttpClient",
"LangGraphClient",
"RunsClient",
"StoreClient",
"ThreadsClient",
"get_client",
]

View File

@@ -0,0 +1,733 @@
"""Async client for managing assistants in LangGraph."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, Literal, cast, overload
import httpx
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk.schema import (
Assistant,
AssistantSelectField,
AssistantSortBy,
AssistantsSearchResponse,
AssistantVersion,
Config,
Context,
GraphSchema,
Json,
OnConflictBehavior,
QueryParamTypes,
SortOrder,
Subgraphs,
)
class AssistantsClient:
"""Client for managing assistants in LangGraph.
This class provides methods to interact with assistants,
which are versioned configurations of your graph.
???+ example "Example"
```python
client = get_client(url="http://localhost:2024")
assistant = await client.assistants.get("assistant_id_123")
```
"""
def __init__(self, http: HttpClient) -> None:
self.http = http
async def get(
self,
assistant_id: str,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Assistant:
"""Get an assistant by ID.
Args:
assistant_id: The ID of the assistant to get.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
Assistant: Assistant Object.
???+ example "Example Usage"
```python
assistant = await client.assistants.get(
assistant_id="my_assistant_id"
)
print(assistant)
```
```shell
----------------------------------------------------
{
'assistant_id': 'my_assistant_id',
'graph_id': 'agent',
'created_at': '2024-06-25T17:10:33.109781+00:00',
'updated_at': '2024-06-25T17:10:33.109781+00:00',
'config': {},
'metadata': {'created_by': 'system'},
'version': 1,
'name': 'my_assistant'
}
```
"""
return await self.http.get(
f"/assistants/{assistant_id}", headers=headers, params=params
)
async def get_graph(
self,
assistant_id: str,
*,
xray: int | bool = False,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> dict[str, list[dict[str, Any]]]:
"""Get the graph of an assistant by ID.
Args:
assistant_id: The ID of the assistant to get the graph of.
xray: Include graph representation of subgraphs. If an integer value is provided, only subgraphs with a depth less than or equal to the value will be included.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
Graph: The graph information for the assistant in JSON format.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
graph_info = await client.assistants.get_graph(
assistant_id="my_assistant_id"
)
print(graph_info)
```
```shell
--------------------------------------------------------------------------------------------------------------------------
{
'nodes':
[
{'id': '__start__', 'type': 'schema', 'data': '__start__'},
{'id': '__end__', 'type': 'schema', 'data': '__end__'},
{'id': 'agent','type': 'runnable','data': {'id': ['langgraph', 'utils', 'RunnableCallable'],'name': 'agent'}},
],
'edges':
[
{'source': '__start__', 'target': 'agent'},
{'source': 'agent','target': '__end__'}
]
}
```
"""
query_params = {"xray": xray}
if params:
query_params.update(params)
return await self.http.get(
f"/assistants/{assistant_id}/graph", params=query_params, headers=headers
)
async def get_schemas(
self,
assistant_id: str,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> GraphSchema:
"""Get the schemas of an assistant by ID.
Args:
assistant_id: The ID of the assistant to get the schema of.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
GraphSchema: The graph schema for the assistant.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
schema = await client.assistants.get_schemas(
assistant_id="my_assistant_id"
)
print(schema)
```
```shell
----------------------------------------------------------------------------------------------------------------------------
{
'graph_id': 'agent',
'state_schema':
{
'title': 'LangGraphInput',
'$ref': '#/definitions/AgentState',
'definitions':
{
'BaseMessage':
{
'title': 'BaseMessage',
'description': 'Base abstract Message class. Messages are the inputs and outputs of ChatModels.',
'type': 'object',
'properties':
{
'content':
{
'title': 'Content',
'anyOf': [
{'type': 'string'},
{'type': 'array','items': {'anyOf': [{'type': 'string'}, {'type': 'object'}]}}
]
},
'additional_kwargs':
{
'title': 'Additional Kwargs',
'type': 'object'
},
'response_metadata':
{
'title': 'Response Metadata',
'type': 'object'
},
'type':
{
'title': 'Type',
'type': 'string'
},
'name':
{
'title': 'Name',
'type': 'string'
},
'id':
{
'title': 'Id',
'type': 'string'
}
},
'required': ['content', 'type']
},
'AgentState':
{
'title': 'AgentState',
'type': 'object',
'properties':
{
'messages':
{
'title': 'Messages',
'type': 'array',
'items': {'$ref': '#/definitions/BaseMessage'}
}
},
'required': ['messages']
}
}
},
'context_schema':
{
'title': 'Context',
'type': 'object',
'properties':
{
'model_name':
{
'title': 'Model Name',
'enum': ['anthropic', 'openai'],
'type': 'string'
}
}
}
}
```
"""
return await self.http.get(
f"/assistants/{assistant_id}/schemas", headers=headers, params=params
)
async def get_subgraphs(
self,
assistant_id: str,
namespace: str | None = None,
recurse: bool = False,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Subgraphs:
"""Get the schemas of an assistant by ID.
Args:
assistant_id: The ID of the assistant to get the schema of.
namespace: Optional namespace to filter by.
recurse: Whether to recursively get subgraphs.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
Subgraphs: The graph schema for the assistant.
"""
get_params = {"recurse": recurse}
if params:
get_params = {**get_params, **dict(params)}
if namespace is not None:
return await self.http.get(
f"/assistants/{assistant_id}/subgraphs/{namespace}",
params=get_params,
headers=headers,
)
else:
return await self.http.get(
f"/assistants/{assistant_id}/subgraphs",
params=get_params,
headers=headers,
)
async def create(
self,
graph_id: str | None,
config: Config | None = None,
*,
context: Context | None = None,
metadata: Json = None,
assistant_id: str | None = None,
if_exists: OnConflictBehavior | None = None,
name: str | None = None,
headers: Mapping[str, str] | None = None,
description: str | None = None,
params: QueryParamTypes | None = None,
) -> Assistant:
"""Create a new assistant.
Useful when graph is configurable and you want to create different assistants based on different configurations.
Args:
graph_id: The ID of the graph the assistant should use. The graph ID is normally set in your langgraph.json configuration.
config: Configuration to use for the graph.
metadata: Metadata to add to assistant.
context: Static context to add to the assistant.
!!! version-added "Added in version 0.6.0"
assistant_id: Assistant ID to use, will default to a random UUID if not provided.
if_exists: How to handle duplicate creation. Defaults to 'raise' under the hood.
Must be either 'raise' (raise error if duplicate), or 'do_nothing' (return existing assistant).
name: The name of the assistant. Defaults to 'Untitled' under the hood.
headers: Optional custom headers to include with the request.
description: Optional description of the assistant.
The description field is available for langgraph-api server version>=0.0.45
params: Optional query parameters to include with the request.
Returns:
Assistant: The created assistant.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
assistant = await client.assistants.create(
graph_id="agent",
context={"model_name": "openai"},
metadata={"number":1},
assistant_id="my-assistant-id",
if_exists="do_nothing",
name="my_name"
)
```
"""
payload: dict[str, Any] = {
"graph_id": graph_id,
}
if config:
payload["config"] = config
if context:
payload["context"] = context
if metadata:
payload["metadata"] = metadata
if assistant_id:
payload["assistant_id"] = assistant_id
if if_exists:
payload["if_exists"] = if_exists
if name:
payload["name"] = name
if description:
payload["description"] = description
return await self.http.post(
"/assistants", json=payload, headers=headers, params=params
)
async def update(
self,
assistant_id: str,
*,
graph_id: str | None = None,
config: Config | None = None,
context: Context | None = None,
metadata: Json = None,
name: str | None = None,
headers: Mapping[str, str] | None = None,
description: str | None = None,
params: QueryParamTypes | None = None,
) -> Assistant:
"""Update an assistant.
Use this to point to a different graph, update the configuration, or change the metadata of an assistant.
Args:
assistant_id: Assistant to update.
graph_id: The ID of the graph the assistant should use.
The graph ID is normally set in your langgraph.json configuration. If `None`, assistant will keep pointing to same graph.
config: Configuration to use for the graph.
context: Static context to add to the assistant.
!!! version-added "Added in version 0.6.0"
metadata: Metadata to merge with existing assistant metadata.
name: The new name for the assistant.
headers: Optional custom headers to include with the request.
description: Optional description of the assistant.
The description field is available for langgraph-api server version>=0.0.45
params: Optional query parameters to include with the request.
Returns:
The updated assistant.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
assistant = await client.assistants.update(
assistant_id='e280dad7-8618-443f-87f1-8e41841c180f',
graph_id="other-graph",
context={"model_name": "anthropic"},
metadata={"number":2}
)
```
"""
payload: dict[str, Any] = {}
if graph_id:
payload["graph_id"] = graph_id
if config is not None:
payload["config"] = config
if context is not None:
payload["context"] = context
if metadata:
payload["metadata"] = metadata
if name:
payload["name"] = name
if description:
payload["description"] = description
return await self.http.patch(
f"/assistants/{assistant_id}",
json=payload,
headers=headers,
params=params,
)
async def delete(
self,
assistant_id: str,
*,
delete_threads: bool = False,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> None:
"""Delete an assistant.
Args:
assistant_id: The assistant ID to delete.
delete_threads: If true, delete all threads with `metadata.assistant_id`
matching this assistant, along with runs and checkpoints belonging to
those threads.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
`None`
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
await client.assistants.delete(
assistant_id="my_assistant_id"
)
```
"""
query_params: dict[str, Any] = {}
if delete_threads:
query_params["delete_threads"] = True
if params:
query_params.update(params)
await self.http.delete(
f"/assistants/{assistant_id}",
headers=headers,
params=query_params or None,
)
@overload
async def search(
self,
*,
metadata: Json = None,
graph_id: str | None = None,
name: str | None = None,
limit: int = 10,
offset: int = 0,
sort_by: AssistantSortBy | None = None,
sort_order: SortOrder | None = None,
select: list[AssistantSelectField] | None = None,
response_format: Literal["object"],
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> AssistantsSearchResponse: ...
@overload
async def search(
self,
*,
metadata: Json = None,
graph_id: str | None = None,
name: str | None = None,
limit: int = 10,
offset: int = 0,
sort_by: AssistantSortBy | None = None,
sort_order: SortOrder | None = None,
select: list[AssistantSelectField] | None = None,
response_format: Literal["array"] = "array",
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> list[Assistant]: ...
async def search(
self,
*,
metadata: Json = None,
graph_id: str | None = None,
name: str | None = None,
limit: int = 10,
offset: int = 0,
sort_by: AssistantSortBy | None = None,
sort_order: SortOrder | None = None,
select: list[AssistantSelectField] | None = None,
response_format: Literal["array", "object"] = "array",
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> AssistantsSearchResponse | list[Assistant]:
"""Search for assistants.
Args:
metadata: Metadata to filter by. Exact match filter for each KV pair.
graph_id: The ID of the graph to filter by.
The graph ID is normally set in your langgraph.json configuration.
name: The name of the assistant to filter by.
The filtering logic will match assistants where 'name' is a substring (case insensitive) of the assistant name.
limit: The maximum number of results to return.
offset: The number of results to skip.
sort_by: The field to sort by.
sort_order: The order to sort by.
select: Specific assistant fields to include in the response.
response_format: Controls the response shape. Use `"array"` (default)
to return a bare list of assistants, or `"object"` to return
a mapping containing assistants plus pagination metadata.
Defaults to "array", though this default will be changed to "object" in a future release.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
A list of assistants (when `response_format="array"`) or a mapping
with the assistants and the next pagination cursor (when
`response_format="object"`).
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
response = await client.assistants.search(
metadata = {"name":"my_name"},
graph_id="my_graph_id",
limit=5,
offset=5,
response_format="object"
)
next_cursor = response["next"]
assistants = response["assistants"]
```
"""
if response_format not in ("array", "object"):
raise ValueError(
f"response_format must be 'array' or 'object', got {response_format!r}"
)
payload: dict[str, Any] = {
"limit": limit,
"offset": offset,
}
if metadata:
payload["metadata"] = metadata
if graph_id:
payload["graph_id"] = graph_id
if name:
payload["name"] = name
if sort_by:
payload["sort_by"] = sort_by
if sort_order:
payload["sort_order"] = sort_order
if select:
payload["select"] = select
next_cursor: str | None = None
def capture_pagination(response: httpx.Response) -> None:
nonlocal next_cursor
next_cursor = response.headers.get("X-Pagination-Next")
assistants = cast(
list[Assistant],
await self.http.post(
"/assistants/search",
json=payload,
headers=headers,
params=params,
on_response=capture_pagination if response_format == "object" else None,
),
)
if response_format == "object":
return {"assistants": assistants, "next": next_cursor}
return assistants
async def count(
self,
*,
metadata: Json = None,
graph_id: str | None = None,
name: str | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> int:
"""Count assistants matching filters.
Args:
metadata: Metadata to filter by. Exact match for each key/value.
graph_id: Optional graph id to filter by.
name: Optional name to filter by.
The filtering logic will match assistants where 'name' is a substring (case insensitive) of the assistant name.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
int: Number of assistants matching the criteria.
"""
payload: dict[str, Any] = {}
if metadata:
payload["metadata"] = metadata
if graph_id:
payload["graph_id"] = graph_id
if name:
payload["name"] = name
return await self.http.post(
"/assistants/count", json=payload, headers=headers, params=params
)
async def get_versions(
self,
assistant_id: str,
metadata: Json = None,
limit: int = 10,
offset: int = 0,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> list[AssistantVersion]:
"""List all versions of an assistant.
Args:
assistant_id: The assistant ID to get versions for.
metadata: Metadata to filter versions by. Exact match filter for each KV pair.
limit: The maximum number of versions to return.
offset: The number of versions to skip.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
A list of assistant versions.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
assistant_versions = await client.assistants.get_versions(
assistant_id="my_assistant_id"
)
```
"""
payload: dict[str, Any] = {
"limit": limit,
"offset": offset,
}
if metadata:
payload["metadata"] = metadata
return await self.http.post(
f"/assistants/{assistant_id}/versions",
json=payload,
headers=headers,
params=params,
)
async def set_latest(
self,
assistant_id: str,
version: int,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Assistant:
"""Change the version of an assistant.
Args:
assistant_id: The assistant ID to delete.
version: The version to change to.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
Assistant Object.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
new_version_assistant = await client.assistants.set_latest(
assistant_id="my_assistant_id",
version=3
)
```
"""
payload: dict[str, Any] = {"version": version}
return await self.http.post(
f"/assistants/{assistant_id}/latest",
json=payload,
headers=headers,
params=params,
)

View File

@@ -0,0 +1,178 @@
"""Async LangGraph client."""
from __future__ import annotations
import logging
import os
from collections.abc import Mapping
from types import TracebackType
import httpx
from langgraph_sdk._async.assistants import AssistantsClient
from langgraph_sdk._async.cron import CronClient
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.runs import RunsClient
from langgraph_sdk._async.store import StoreClient
from langgraph_sdk._async.threads import ThreadsClient
from langgraph_sdk._shared.types import TimeoutTypes
from langgraph_sdk._shared.utilities import (
NOT_PROVIDED,
_get_headers,
_registered_transports,
get_asgi_transport,
)
logger = logging.getLogger(__name__)
def get_client(
*,
url: str | None = None,
api_key: str | None = NOT_PROVIDED,
headers: Mapping[str, str] | None = None,
timeout: TimeoutTypes | None = None,
) -> LangGraphClient:
"""Create and configure a LangGraphClient.
The client provides programmatic access to LangSmith Deployment. It supports
both remote servers and local in-process connections (when running inside a LangGraph server).
Args:
url:
Base URL of the LangGraph API.
- If `None`, the client first attempts an in-process connection via ASGI transport.
If that fails, it defers registration until after app initialization. This
only works if the client is used from within the Agent server.
api_key:
API key for authentication. Can be:
- A string: use this exact API key
- `None`: explicitly skip loading from environment variables
- Not provided (default): auto-load from environment in this order:
1. `LANGGRAPH_API_KEY`
2. `LANGSMITH_API_KEY`
3. `LANGCHAIN_API_KEY`
headers:
Additional HTTP headers to include in requests. Merged with authentication headers.
timeout:
HTTP timeout configuration. May be:
- `httpx.Timeout` instance
- float (total seconds)
- tuple `(connect, read, write, pool)` in seconds
Defaults: connect=5, read=300, write=300, pool=5.
Returns:
LangGraphClient:
A top-level client exposing sub-clients for assistants, threads,
runs, and cron operations.
???+ example "Connect to a remote server:"
```python
from langgraph_sdk import get_client
# get top-level LangGraphClient
client = get_client(url="http://localhost:8123")
# example usage: client.<model>.<method_name>()
assistants = await client.assistants.get(assistant_id="some_uuid")
```
???+ example "Connect in-process to a running LangGraph server:"
```python
from langgraph_sdk import get_client
client = get_client(url=None)
async def my_node(...):
subagent_result = await client.runs.wait(
thread_id=None,
assistant_id="agent",
input={"messages": [{"role": "user", "content": "Foo"}]},
)
```
???+ example "Skip auto-loading API key from environment:"
```python
from langgraph_sdk import get_client
# Don't load API key from environment variables
client = get_client(
url="http://localhost:8123",
api_key=None
)
```
"""
transport: httpx.AsyncBaseTransport | None = None
if url is None:
url = "http://api"
if os.environ.get("__LANGGRAPH_DEFER_LOOPBACK_TRANSPORT") == "true":
transport = get_asgi_transport()(app=None, root_path="/noauth") # type: ignore[invalid-argument-type]
_registered_transports.append(transport)
else:
try:
from langgraph_api.server import app # type: ignore
transport = get_asgi_transport()(app, root_path="/noauth")
except Exception:
logger.debug(
"Failed to connect to in-process LangGraph server. Deferring configuration.",
exc_info=True,
)
transport = get_asgi_transport()(app=None, root_path="/noauth") # type: ignore[invalid-argument-type]
_registered_transports.append(transport)
if transport is None:
transport = httpx.AsyncHTTPTransport(retries=5)
client = httpx.AsyncClient(
base_url=url,
transport=transport,
timeout=(
httpx.Timeout(timeout) # type: ignore[arg-type]
if timeout is not None
else httpx.Timeout(connect=5, read=300, write=300, pool=5)
),
headers=_get_headers(api_key, headers),
)
return LangGraphClient(client)
class LangGraphClient:
"""Top-level client for LangGraph API.
Attributes:
assistants: Manages versioned configuration for your graphs.
threads: Handles (potentially) multi-turn interactions, such as conversational threads.
runs: Controls individual invocations of the graph.
crons: Manages scheduled operations.
store: Interfaces with persistent, shared data storage.
"""
def __init__(self, client: httpx.AsyncClient) -> None:
self.http = HttpClient(client)
self.assistants = AssistantsClient(self.http)
self.threads = ThreadsClient(self.http)
self.runs = RunsClient(self.http)
self.crons = CronClient(self.http)
self.store = StoreClient(self.http)
async def __aenter__(self) -> LangGraphClient:
"""Enter the async context manager."""
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
"""Exit the async context manager."""
await self.aclose()
async def aclose(self) -> None:
"""Close the underlying HTTP client."""
if hasattr(self, "http"):
await self.http.client.aclose()

View File

@@ -0,0 +1,511 @@
"""Async client for managing recurrent runs (cron jobs) in LangGraph."""
from __future__ import annotations
import warnings
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import Any
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk.schema import (
All,
Config,
Context,
Cron,
CronSelectField,
CronSortBy,
Durability,
Input,
OnCompletionBehavior,
QueryParamTypes,
Run,
SortOrder,
StreamMode,
)
class CronClient:
"""Client for managing recurrent runs (cron jobs) in LangGraph.
A run is a single invocation of an assistant with optional input, config, and context.
This client allows scheduling recurring runs to occur automatically.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024"))
cron_job = await client.crons.create_for_thread(
thread_id="thread_123",
assistant_id="asst_456",
schedule="0 9 * * *",
input={"message": "Daily update"}
)
```
!!! note "Feature Availability"
The crons client functionality is not supported on all licenses.
Please check the relevant license documentation for the most up-to-date
details on feature availability.
"""
def __init__(self, http_client: HttpClient) -> None:
self.http = http_client
async def create_for_thread(
self,
thread_id: str,
assistant_id: str,
*,
schedule: str,
input: Input | None = None,
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
checkpoint_during: bool | None = None, # deprecated
interrupt_before: All | list[str] | None = None,
interrupt_after: All | list[str] | None = None,
webhook: str | None = None,
multitask_strategy: str | None = None,
end_time: datetime | None = None,
enabled: bool | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
durability: Durability | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Run:
"""Create a cron job for a thread.
Args:
thread_id: the thread ID to run the cron job on.
assistant_id: The assistant ID or graph name to use for the cron job.
If using graph name, will default to first assistant created from that graph.
schedule: The cron schedule to execute this job on.
Schedules are interpreted in UTC.
input: The input to the graph.
metadata: Metadata to assign to the cron job runs.
config: The configuration for the assistant.
context: Static context to add to the assistant.
!!! version-added "Added in version 0.6.0"
checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption).
interrupt_before: Nodes to interrupt immediately before they get executed.
interrupt_after: Nodes to Nodes to interrupt immediately after they get executed.
webhook: Webhook to call after LangGraph API call is done.
multitask_strategy: Multitask strategy to use.
Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely.
enabled: Whether the cron job is enabled or not.
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
The cron run.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
cron_run = await client.crons.create_for_thread(
thread_id="my-thread-id",
assistant_id="agent",
schedule="27 15 * * *",
input={"messages": [{"role": "user", "content": "hello!"}]},
metadata={"name":"my_run"},
context={"model_name": "openai"},
interrupt_before=["node_to_stop_before_1","node_to_stop_before_2"],
interrupt_after=["node_to_stop_after_1","node_to_stop_after_2"],
webhook="https://my.fake.webhook.com",
multitask_strategy="interrupt",
enabled=True,
)
```
"""
if checkpoint_during is not None:
warnings.warn(
"`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.",
DeprecationWarning,
stacklevel=2,
)
payload = {
"schedule": schedule,
"input": input,
"config": config,
"metadata": metadata,
"context": context,
"assistant_id": assistant_id,
"checkpoint_during": checkpoint_during,
"interrupt_before": interrupt_before,
"interrupt_after": interrupt_after,
"webhook": webhook,
"end_time": end_time.isoformat() if end_time else None,
"enabled": enabled,
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
"durability": durability,
}
if multitask_strategy:
payload["multitask_strategy"] = multitask_strategy
payload = {k: v for k, v in payload.items() if v is not None}
return await self.http.post(
f"/threads/{thread_id}/runs/crons",
json=payload,
headers=headers,
params=params,
)
async def create(
self,
assistant_id: str,
*,
schedule: str,
input: Input | None = None,
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
checkpoint_during: bool | None = None, # deprecated
interrupt_before: All | list[str] | None = None,
interrupt_after: All | list[str] | None = None,
webhook: str | None = None,
on_run_completed: OnCompletionBehavior | None = None,
multitask_strategy: str | None = None,
end_time: datetime | None = None,
enabled: bool | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
durability: Durability | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Run:
"""Create a cron run.
Args:
assistant_id: The assistant ID or graph name to use for the cron job.
If using graph name, will default to first assistant created from that graph.
schedule: The cron schedule to execute this job on.
Schedules are interpreted in UTC.
input: The input to the graph.
metadata: Metadata to assign to the cron job runs.
config: The configuration for the assistant.
context: Static context to add to the assistant.
!!! version-added "Added in version 0.6.0"
checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption).
interrupt_before: Nodes to interrupt immediately before they get executed.
interrupt_after: Nodes to Nodes to interrupt immediately after they get executed.
webhook: Webhook to call after LangGraph API call is done.
on_run_completed: What to do with the thread after the run completes.
Must be one of 'delete' (default) or 'keep'. 'delete' removes the thread
after execution. 'keep' creates a new thread for each execution but does not
clean them up. Clients are responsible for cleaning up kept threads.
multitask_strategy: Multitask strategy to use.
Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely.
enabled: Whether the cron job is enabled or not.
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
The cron run.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
cron_run = client.crons.create(
assistant_id="agent",
schedule="27 15 * * *",
input={"messages": [{"role": "user", "content": "hello!"}]},
metadata={"name":"my_run"},
context={"model_name": "openai"},
interrupt_before=["node_to_stop_before_1","node_to_stop_before_2"],
interrupt_after=["node_to_stop_after_1","node_to_stop_after_2"],
webhook="https://my.fake.webhook.com",
multitask_strategy="interrupt",
enabled=True,
)
```
"""
if checkpoint_during is not None:
warnings.warn(
"`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.",
DeprecationWarning,
stacklevel=2,
)
payload = {
"schedule": schedule,
"input": input,
"config": config,
"metadata": metadata,
"context": context,
"assistant_id": assistant_id,
"checkpoint_during": checkpoint_during,
"interrupt_before": interrupt_before,
"interrupt_after": interrupt_after,
"webhook": webhook,
"on_run_completed": on_run_completed,
"end_time": end_time.isoformat() if end_time else None,
"enabled": enabled,
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
"durability": durability,
}
if multitask_strategy:
payload["multitask_strategy"] = multitask_strategy
payload = {k: v for k, v in payload.items() if v is not None}
return await self.http.post(
"/runs/crons", json=payload, headers=headers, params=params
)
async def delete(
self,
cron_id: str,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> None:
"""Delete a cron.
Args:
cron_id: The cron ID to delete.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
`None`
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
await client.crons.delete(
cron_id="cron_to_delete"
)
```
"""
await self.http.delete(f"/runs/crons/{cron_id}", headers=headers, params=params)
async def update(
self,
cron_id: str,
*,
schedule: str | None = None,
end_time: datetime | None = None,
input: Input | None = None,
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
webhook: str | None = None,
interrupt_before: All | list[str] | None = None,
interrupt_after: All | list[str] | None = None,
on_run_completed: OnCompletionBehavior | None = None,
enabled: bool | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
durability: Durability | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Cron:
"""Update a cron job by ID.
Args:
cron_id: The cron ID to update.
schedule: The cron schedule to execute this job on.
Schedules are interpreted in UTC.
end_time: The end date to stop running the cron.
input: The input to the graph.
metadata: Metadata to assign to the cron job runs.
config: The configuration for the assistant.
context: Static context added to the assistant.
webhook: Webhook to call after LangGraph API call is done.
interrupt_before: Nodes to interrupt immediately before they get executed.
interrupt_after: Nodes to interrupt immediately after they get executed.
on_run_completed: What to do with the thread after the run completes.
Must be one of 'delete' or 'keep'. 'delete' removes the thread
after execution. 'keep' creates a new thread for each execution but does not
clean them up.
enabled: Enable or disable the cron job.
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
The updated cron job.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
updated_cron = await client.crons.update(
cron_id="1ef3cefa-4c09-6926-96d0-3dc97fd5e39b",
schedule="0 10 * * *",
enabled=False,
)
```
"""
payload = {
"schedule": schedule,
"end_time": end_time.isoformat() if end_time else None,
"input": input,
"metadata": metadata,
"config": config,
"context": context,
"webhook": webhook,
"interrupt_before": interrupt_before,
"interrupt_after": interrupt_after,
"on_run_completed": on_run_completed,
"enabled": enabled,
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
"durability": durability,
}
payload = {k: v for k, v in payload.items() if v is not None}
return await self.http.patch(
f"/runs/crons/{cron_id}",
json=payload,
headers=headers,
params=params,
)
async def search(
self,
*,
assistant_id: str | None = None,
thread_id: str | None = None,
enabled: bool | None = None,
limit: int = 10,
offset: int = 0,
sort_by: CronSortBy | None = None,
sort_order: SortOrder | None = None,
select: list[CronSelectField] | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> list[Cron]:
"""Get a list of cron jobs.
Args:
assistant_id: The assistant ID or graph name to search for.
thread_id: the thread ID to search for.
enabled: The enabled status to search for.
limit: The maximum number of results to return.
offset: The number of results to skip.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
The list of cron jobs returned by the search,
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
cron_jobs = await client.crons.search(
assistant_id="my_assistant_id",
thread_id="my_thread_id",
enabled=True,
limit=5,
offset=5,
)
print(cron_jobs)
```
```shell
----------------------------------------------------------
[
{
'cron_id': '1ef3cefa-4c09-6926-96d0-3dc97fd5e39b',
'assistant_id': 'my_assistant_id',
'thread_id': 'my_thread_id',
'user_id': None,
'payload':
{
'input': {'start_time': ''},
'schedule': '4 * * * *',
'assistant_id': 'my_assistant_id'
},
'schedule': '4 * * * *',
'next_run_date': '2024-07-25T17:04:00+00:00',
'end_time': None,
'created_at': '2024-07-08T06:02:23.073257+00:00',
'updated_at': '2024-07-08T06:02:23.073257+00:00'
}
]
```
"""
payload = {
"assistant_id": assistant_id,
"thread_id": thread_id,
"enabled": enabled,
"limit": limit,
"offset": offset,
}
if sort_by:
payload["sort_by"] = sort_by
if sort_order:
payload["sort_order"] = sort_order
if select:
payload["select"] = select
payload = {k: v for k, v in payload.items() if v is not None}
return await self.http.post(
"/runs/crons/search", json=payload, headers=headers, params=params
)
async def count(
self,
*,
assistant_id: str | None = None,
thread_id: str | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> int:
"""Count cron jobs matching filters.
Args:
assistant_id: Assistant ID to filter by.
thread_id: Thread ID to filter by.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
int: Number of crons matching the criteria.
"""
payload: dict[str, Any] = {}
if assistant_id:
payload["assistant_id"] = assistant_id
if thread_id:
payload["thread_id"] = thread_id
return await self.http.post(
"/runs/crons/count", json=payload, headers=headers, params=params
)

View File

@@ -0,0 +1,305 @@
"""HTTP client for async operations."""
from __future__ import annotations
import asyncio
import logging
import sys
import warnings
from collections.abc import AsyncIterator, Callable, Mapping
from typing import Any, cast
import httpx
import orjson
from langgraph_sdk._shared.utilities import _orjson_default
from langgraph_sdk.errors import _araise_for_status_typed
from langgraph_sdk.schema import QueryParamTypes, StreamPart
from langgraph_sdk.sse import SSEDecoder, aiter_lines_raw
logger = logging.getLogger(__name__)
class HttpClient:
"""Handle async requests to the LangGraph API.
Adds additional error messaging & content handling above the
provided httpx client.
Attributes:
client (httpx.AsyncClient): Underlying HTTPX async client.
"""
def __init__(self, client: httpx.AsyncClient) -> None:
self.client = client
async def get(
self,
path: str,
*,
params: QueryParamTypes | None = None,
headers: Mapping[str, str] | None = None,
on_response: Callable[[httpx.Response], None] | None = None,
) -> Any:
"""Send a `GET` request."""
r = await self.client.get(path, params=params, headers=headers)
if on_response:
on_response(r)
await _araise_for_status_typed(r)
return await _adecode_json(r)
async def post(
self,
path: str,
*,
json: dict[str, Any] | list | None,
params: QueryParamTypes | None = None,
headers: Mapping[str, str] | None = None,
on_response: Callable[[httpx.Response], None] | None = None,
) -> Any:
"""Send a `POST` request."""
if json is not None:
request_headers, content = await _aencode_json(json)
else:
request_headers, content = {}, b""
# Merge headers, with runtime headers taking precedence
if headers:
request_headers.update(headers)
r = await self.client.post(
path, headers=request_headers, content=content, params=params
)
if on_response:
on_response(r)
await _araise_for_status_typed(r)
return await _adecode_json(r)
async def put(
self,
path: str,
*,
json: dict,
params: QueryParamTypes | None = None,
headers: Mapping[str, str] | None = None,
on_response: Callable[[httpx.Response], None] | None = None,
) -> Any:
"""Send a `PUT` request."""
request_headers, content = await _aencode_json(json)
if headers:
request_headers.update(headers)
r = await self.client.put(
path, headers=request_headers, content=content, params=params
)
if on_response:
on_response(r)
await _araise_for_status_typed(r)
return await _adecode_json(r)
async def patch(
self,
path: str,
*,
json: dict,
params: QueryParamTypes | None = None,
headers: Mapping[str, str] | None = None,
on_response: Callable[[httpx.Response], None] | None = None,
) -> Any:
"""Send a `PATCH` request."""
request_headers, content = await _aencode_json(json)
if headers:
request_headers.update(headers)
r = await self.client.patch(
path, headers=request_headers, content=content, params=params
)
if on_response:
on_response(r)
await _araise_for_status_typed(r)
return await _adecode_json(r)
async def delete(
self,
path: str,
*,
json: Any | None = None,
params: QueryParamTypes | None = None,
headers: Mapping[str, str] | None = None,
on_response: Callable[[httpx.Response], None] | None = None,
) -> None:
"""Send a `DELETE` request."""
r = await self.client.request(
"DELETE", path, json=json, params=params, headers=headers
)
if on_response:
on_response(r)
await _araise_for_status_typed(r)
async def request_reconnect(
self,
path: str,
method: str,
*,
json: dict[str, Any] | None = None,
params: QueryParamTypes | None = None,
headers: Mapping[str, str] | None = None,
on_response: Callable[[httpx.Response], None] | None = None,
reconnect_limit: int = 5,
) -> Any:
"""Send a request that automatically reconnects to Location header."""
request_headers, content = await _aencode_json(json)
if headers:
request_headers.update(headers)
async with self.client.stream(
method, path, headers=request_headers, content=content, params=params
) as r:
if on_response:
on_response(r)
try:
r.raise_for_status()
except httpx.HTTPStatusError as e:
body = (await r.aread()).decode()
if sys.version_info >= (3, 11):
e.add_note(body)
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
loc = r.headers.get("location")
if reconnect_limit <= 0 or not loc:
return await _adecode_json(r)
try:
return await _adecode_json(r)
except httpx.HTTPError:
warnings.warn(
f"Request failed, attempting reconnect to Location: {loc}",
stacklevel=2,
)
await r.aclose()
return await self.request_reconnect(
loc,
"GET",
headers=request_headers,
# don't pass on_response so it's only called once
reconnect_limit=reconnect_limit - 1,
)
async def stream(
self,
path: str,
method: str,
*,
json: dict[str, Any] | None = None,
params: QueryParamTypes | None = None,
headers: Mapping[str, str] | None = None,
on_response: Callable[[httpx.Response], None] | None = None,
) -> AsyncIterator[StreamPart]:
"""Stream results using SSE."""
request_headers, content = await _aencode_json(json)
request_headers["Accept"] = "text/event-stream"
request_headers["Cache-Control"] = "no-store"
# Add runtime headers with precedence
if headers:
request_headers.update(headers)
reconnect_headers = {
key: value
for key, value in request_headers.items()
if key.lower() not in {"content-length", "content-type"}
}
last_event_id: str | None = None
reconnect_path: str | None = None
reconnect_attempts = 0
max_reconnect_attempts = 5
while True:
current_headers = dict(
request_headers if reconnect_path is None else reconnect_headers
)
if last_event_id is not None:
current_headers["Last-Event-ID"] = last_event_id
current_method = method if reconnect_path is None else "GET"
current_content = content if reconnect_path is None else None
current_params = params if reconnect_path is None else None
retry = False
async with self.client.stream(
current_method,
reconnect_path or path,
headers=current_headers,
content=current_content,
params=current_params,
) as res:
if reconnect_path is None and on_response:
on_response(res)
# check status
await _araise_for_status_typed(res)
# check content type
content_type = res.headers.get("content-type", "").partition(";")[0]
if "text/event-stream" not in content_type:
raise httpx.TransportError(
"Expected response header Content-Type to contain 'text/event-stream', "
f"got {content_type!r}"
)
reconnect_location = res.headers.get("location")
if reconnect_location:
reconnect_path = reconnect_location
# parse SSE
decoder = SSEDecoder()
try:
async for line in aiter_lines_raw(res):
sse = decoder.decode(line=cast("bytes", line).rstrip(b"\n"))
if sse is not None:
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or sse.data is not None:
yield sse
except httpx.HTTPError:
# httpx.TransportError inherits from HTTPError, so transient
# disconnects during streaming land here.
if reconnect_path is None:
raise
retry = True
else:
if sse := decoder.decode(b""):
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or sse.data is not None:
# decoder.decode(b"") flushes the in-flight event and may
# return an empty placeholder when there is no pending
# message. Skip these no-op events so the stream doesn't
# emit a trailing blank item after reconnects.
yield sse
if retry:
reconnect_attempts += 1
if reconnect_attempts > max_reconnect_attempts:
raise httpx.TransportError(
"Exceeded maximum SSE reconnection attempts"
)
continue
break
async def _aencode_json(json: Any) -> tuple[dict[str, str], bytes | None]:
if json is None:
return {}, None
body = await asyncio.get_running_loop().run_in_executor(
None,
orjson.dumps,
json,
_orjson_default,
orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS,
)
content_length = str(len(body))
content_type = "application/json"
headers = {"Content-Length": content_length, "Content-Type": content_type}
return headers, body
async def _adecode_json(r: httpx.Response) -> Any:
body = await r.aread()
return (
await asyncio.get_running_loop().run_in_executor(None, orjson.loads, body)
if body
else None
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,313 @@
"""Async Store client for LangGraph SDK."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any, Literal
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._shared.utilities import _provided_vals
from langgraph_sdk.schema import (
Item,
ListNamespaceResponse,
QueryParamTypes,
SearchItemsResponse,
)
class StoreClient:
"""Client for interacting with the graph's shared storage.
The Store provides a key-value storage system for persisting data across graph executions,
allowing for stateful operations and data sharing across threads.
???+ example "Example"
```python
client = get_client(url="http://localhost:2024")
await client.store.put_item(["users", "user123"], "mem-123451342", {"name": "Alice", "score": 100})
```
"""
def __init__(self, http: HttpClient) -> None:
self.http = http
async def put_item(
self,
namespace: Sequence[str],
/,
key: str,
value: Mapping[str, Any],
index: Literal[False] | list[str] | None = None,
ttl: int | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> None:
"""Store or update an item.
Args:
namespace: A list of strings representing the namespace path.
key: The unique identifier for the item within the namespace.
value: A dictionary containing the item's data.
index: Controls search indexing - None (use defaults), False (disable), or list of field paths to index.
ttl: Optional time-to-live in minutes for the item, or None for no expiration.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
`None`
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
await client.store.put_item(
["documents", "user123"],
key="item456",
value={"title": "My Document", "content": "Hello World"}
)
```
"""
for label in namespace:
if "." in label:
raise ValueError(
f"Invalid namespace label '{label}'. Namespace labels cannot contain periods ('.')."
)
payload = {
"namespace": namespace,
"key": key,
"value": value,
"index": index,
"ttl": ttl,
}
await self.http.put(
"/store/items", json=_provided_vals(payload), headers=headers, params=params
)
async def get_item(
self,
namespace: Sequence[str],
/,
key: str,
*,
refresh_ttl: bool | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Item:
"""Retrieve a single item.
Args:
key: The unique identifier for the item.
namespace: Optional list of strings representing the namespace path.
refresh_ttl: Whether to refresh the TTL on this read operation. If `None`, uses the store's default behavior.
Returns:
Item: The retrieved item.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
item = await client.store.get_item(
["documents", "user123"],
key="item456",
)
print(item)
```
```shell
----------------------------------------------------------------
{
'namespace': ['documents', 'user123'],
'key': 'item456',
'value': {'title': 'My Document', 'content': 'Hello World'},
'created_at': '2024-07-30T12:00:00Z',
'updated_at': '2024-07-30T12:00:00Z'
}
```
"""
for label in namespace:
if "." in label:
raise ValueError(
f"Invalid namespace label '{label}'. Namespace labels cannot contain periods ('.')."
)
get_params = {"namespace": ".".join(namespace), "key": key}
if refresh_ttl is not None:
get_params["refresh_ttl"] = refresh_ttl
if params:
get_params = {**get_params, **dict(params)}
return await self.http.get("/store/items", params=get_params, headers=headers)
async def delete_item(
self,
namespace: Sequence[str],
/,
key: str,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> None:
"""Delete an item.
Args:
key: The unique identifier for the item.
namespace: Optional list of strings representing the namespace path.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
`None`
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
await client.store.delete_item(
["documents", "user123"],
key="item456",
)
```
"""
await self.http.delete(
"/store/items",
json={"namespace": namespace, "key": key},
headers=headers,
params=params,
)
async def search_items(
self,
namespace_prefix: Sequence[str],
/,
filter: Mapping[str, Any] | None = None,
limit: int = 10,
offset: int = 0,
query: str | None = None,
refresh_ttl: bool | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> SearchItemsResponse:
"""Search for items within a namespace prefix.
Args:
namespace_prefix: List of strings representing the namespace prefix.
filter: Optional dictionary of key-value pairs to filter results.
limit: Maximum number of items to return (default is 10).
offset: Number of items to skip before returning results (default is 0).
query: Optional query for natural language search.
refresh_ttl: Whether to refresh the TTL on items returned by this search. If `None`, uses the store's default behavior.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
A list of items matching the search criteria.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
items = await client.store.search_items(
["documents"],
filter={"author": "John Doe"},
limit=5,
offset=0
)
print(items)
```
```shell
----------------------------------------------------------------
{
"items": [
{
"namespace": ["documents", "user123"],
"key": "item789",
"value": {
"title": "Another Document",
"author": "John Doe"
},
"created_at": "2024-07-30T12:00:00Z",
"updated_at": "2024-07-30T12:00:00Z"
},
# ... additional items ...
]
}
```
"""
payload = {
"namespace_prefix": namespace_prefix,
"filter": filter,
"limit": limit,
"offset": offset,
"query": query,
"refresh_ttl": refresh_ttl,
}
return await self.http.post(
"/store/items/search",
json=_provided_vals(payload),
headers=headers,
params=params,
)
async def list_namespaces(
self,
prefix: list[str] | None = None,
suffix: list[str] | None = None,
max_depth: int | None = None,
limit: int = 100,
offset: int = 0,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> ListNamespaceResponse:
"""List namespaces with optional match conditions.
Args:
prefix: Optional list of strings representing the prefix to filter namespaces.
suffix: Optional list of strings representing the suffix to filter namespaces.
max_depth: Optional integer specifying the maximum depth of namespaces to return.
limit: Maximum number of namespaces to return (default is 100).
offset: Number of namespaces to skip before returning results (default is 0).
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
A list of namespaces matching the criteria.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
namespaces = await client.store.list_namespaces(
prefix=["documents"],
max_depth=3,
limit=10,
offset=0
)
print(namespaces)
----------------------------------------------------------------
[
["documents", "user123", "reports"],
["documents", "user456", "invoices"],
...
]
```
"""
payload = {
"prefix": prefix,
"suffix": suffix,
"max_depth": max_depth,
"limit": limit,
"offset": offset,
}
return await self.http.post(
"/store/namespaces",
json=_provided_vals(payload),
headers=headers,
params=params,
)

View File

@@ -0,0 +1,732 @@
"""Async client for managing threads in LangGraph."""
from __future__ import annotations
from collections.abc import AsyncIterator, Mapping, Sequence
from typing import Any
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk.schema import (
Checkpoint,
Json,
OnConflictBehavior,
PruneStrategy,
QueryParamTypes,
SortOrder,
StreamPart,
Thread,
ThreadSelectField,
ThreadSortBy,
ThreadState,
ThreadStatus,
ThreadStreamMode,
ThreadUpdateStateResponse,
)
class ThreadsClient:
"""Client for managing threads in LangGraph.
A thread maintains the state of a graph across multiple interactions/invocations (aka runs).
It accumulates and persists the graph's state, allowing for continuity between separate
invocations of the graph.
???+ example "Example"
```python
client = get_client(url="http://localhost:2024"))
new_thread = await client.threads.create(metadata={"user_id": "123"})
```
"""
def __init__(self, http: HttpClient) -> None:
self.http = http
async def get(
self,
thread_id: str,
*,
include: Sequence[str] | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Thread:
"""Get a thread by ID.
Args:
thread_id: The ID of the thread to get.
include: Additional fields to include in the response.
Supported values: `"ttl"`.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
Thread object.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
thread = await client.threads.get(
thread_id="my_thread_id"
)
print(thread)
```
```shell
-----------------------------------------------------
{
'thread_id': 'my_thread_id',
'created_at': '2024-07-18T18:35:15.540834+00:00',
'updated_at': '2024-07-18T18:35:15.540834+00:00',
'metadata': {'graph_id': 'agent'}
}
```
"""
query_params: dict[str, Any] = {}
if include:
query_params["include"] = ",".join(include)
if params:
query_params.update(params)
return await self.http.get(
f"/threads/{thread_id}",
headers=headers,
params=query_params or None,
)
async def create(
self,
*,
metadata: Json = None,
thread_id: str | None = None,
if_exists: OnConflictBehavior | None = None,
supersteps: Sequence[dict[str, Sequence[dict[str, Any]]]] | None = None,
graph_id: str | None = None,
ttl: int | Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Thread:
"""Create a new thread.
Args:
metadata: Metadata to add to thread.
thread_id: ID of thread.
If `None`, ID will be a randomly generated UUID.
if_exists: How to handle duplicate creation. Defaults to 'raise' under the hood.
Must be either 'raise' (raise error if duplicate), or 'do_nothing' (return existing thread).
supersteps: Apply a list of supersteps when creating a thread, each containing a sequence of updates.
Each update has `values` or `command` and `as_node`. Used for copying a thread between deployments.
graph_id: Optional graph ID to associate with the thread.
ttl: Optional time-to-live in minutes for the thread. You can pass an
integer (minutes) or a mapping with keys `ttl` and optional
`strategy` (defaults to "delete").
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
The created thread.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
thread = await client.threads.create(
metadata={"number":1},
thread_id="my-thread-id",
if_exists="raise"
)
```
"""
payload: dict[str, Any] = {}
if thread_id:
payload["thread_id"] = thread_id
if metadata or graph_id:
payload["metadata"] = {
**(metadata or {}),
**({"graph_id": graph_id} if graph_id else {}),
}
if if_exists:
payload["if_exists"] = if_exists
if supersteps:
payload["supersteps"] = [
{
"updates": [
{
"values": u["values"],
"command": u.get("command"),
"as_node": u["as_node"],
}
for u in s["updates"]
]
}
for s in supersteps
]
if ttl is not None:
if isinstance(ttl, (int, float)):
payload["ttl"] = {"ttl": ttl, "strategy": "delete"}
else:
payload["ttl"] = ttl
return await self.http.post(
"/threads", json=payload, headers=headers, params=params
)
async def update(
self,
thread_id: str,
*,
metadata: Mapping[str, Any],
ttl: int | Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Thread:
"""Update a thread.
Args:
thread_id: ID of thread to update.
metadata: Metadata to merge with existing thread metadata.
ttl: Optional time-to-live in minutes for the thread. You can pass an
integer (minutes) or a mapping with keys `ttl` and optional
`strategy` (defaults to "delete").
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
The created thread.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
thread = await client.threads.update(
thread_id="my-thread-id",
metadata={"number":1},
ttl=43_200,
)
```
"""
payload: dict[str, Any] = {"metadata": metadata}
if ttl is not None:
if isinstance(ttl, (int, float)):
payload["ttl"] = {"ttl": ttl, "strategy": "delete"}
else:
payload["ttl"] = ttl
return await self.http.patch(
f"/threads/{thread_id}",
json=payload,
headers=headers,
params=params,
)
async def delete(
self,
thread_id: str,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> None:
"""Delete a thread.
Args:
thread_id: The ID of the thread to delete.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
`None`
???+ example "Example Usage"
```python
client = get_client(url="http://localhost2024)
await client.threads.delete(
thread_id="my_thread_id"
)
```
"""
await self.http.delete(f"/threads/{thread_id}", headers=headers, params=params)
async def search(
self,
*,
metadata: Json = None,
values: Json = None,
ids: Sequence[str] | None = None,
status: ThreadStatus | None = None,
limit: int = 10,
offset: int = 0,
sort_by: ThreadSortBy | None = None,
sort_order: SortOrder | None = None,
select: list[ThreadSelectField] | None = None,
extract: dict[str, str] | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> list[Thread]:
"""Search for threads.
Args:
metadata: Thread metadata to filter on.
values: State values to filter on.
ids: List of thread IDs to filter by.
status: Thread status to filter on.
Must be one of 'idle', 'busy', 'interrupted' or 'error'.
limit: Limit on number of threads to return.
offset: Offset in threads table to start search from.
sort_by: Sort by field.
sort_order: Sort order.
select: List of fields to include in the response.
extract: Dictionary mapping aliases to JSONB paths to extract
from thread data. Paths use dot notation for nested keys and
bracket notation for array indices (e.g.,
`{"last_msg": "values.messages[-1]"}`). Extracted values are
returned in an `extracted` field on each thread. Maximum 10
paths per request.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
List of the threads matching the search parameters.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
threads = await client.threads.search(
metadata={"number":1},
status="interrupted",
limit=15,
offset=5
)
```
"""
payload: dict[str, Any] = {
"limit": limit,
"offset": offset,
}
if metadata:
payload["metadata"] = metadata
if values:
payload["values"] = values
if ids:
payload["ids"] = ids
if status:
payload["status"] = status
if sort_by:
payload["sort_by"] = sort_by
if sort_order:
payload["sort_order"] = sort_order
if select:
payload["select"] = select
if extract:
payload["extract"] = extract
return await self.http.post(
"/threads/search",
json=payload,
headers=headers,
params=params,
)
async def count(
self,
*,
metadata: Json = None,
values: Json = None,
status: ThreadStatus | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> int:
"""Count threads matching filters.
Args:
metadata: Thread metadata to filter on.
values: State values to filter on.
status: Thread status to filter on.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
int: Number of threads matching the criteria.
"""
payload: dict[str, Any] = {}
if metadata:
payload["metadata"] = metadata
if values:
payload["values"] = values
if status:
payload["status"] = status
return await self.http.post(
"/threads/count", json=payload, headers=headers, params=params
)
async def copy(
self,
thread_id: str,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> None:
"""Copy a thread.
Args:
thread_id: The ID of the thread to copy.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
`None`
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024)
await client.threads.copy(
thread_id="my_thread_id"
)
```
"""
return await self.http.post(
f"/threads/{thread_id}/copy", json=None, headers=headers, params=params
)
async def prune(
self,
thread_ids: Sequence[str],
*,
strategy: PruneStrategy = "delete",
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> dict[str, Any]:
"""Prune threads by ID.
Args:
thread_ids: List of thread IDs to prune.
strategy: The prune strategy. `"delete"` removes threads entirely.
`"keep_latest"` prunes old checkpoints but keeps threads and their
latest state. Defaults to `"delete"`.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
A dict containing `pruned_count` (number of threads pruned).
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024")
result = await client.threads.prune(
thread_ids=["thread_1", "thread_2"],
)
print(result) # {'pruned_count': 2}
```
"""
payload: dict[str, Any] = {
"thread_ids": thread_ids,
}
if strategy != "delete":
payload["strategy"] = strategy
return await self.http.post(
"/threads/prune", json=payload, headers=headers, params=params
)
async def get_state(
self,
thread_id: str,
checkpoint: Checkpoint | None = None,
checkpoint_id: str | None = None, # deprecated
*,
subgraphs: bool = False,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> ThreadState:
"""Get the state of a thread.
Args:
thread_id: The ID of the thread to get the state of.
checkpoint: The checkpoint to get the state of.
checkpoint_id: (deprecated) The checkpoint ID to get the state of.
subgraphs: Include subgraphs states.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
The thread of the state.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024)
thread_state = await client.threads.get_state(
thread_id="my_thread_id",
checkpoint_id="my_checkpoint_id"
)
print(thread_state)
```
```shell
----------------------------------------------------------------------------------------------------------------------------------------------------------------------
{
'values': {
'messages': [
{
'content': 'how are you?',
'additional_kwargs': {},
'response_metadata': {},
'type': 'human',
'name': None,
'id': 'fe0a5778-cfe9-42ee-b807-0adaa1873c10',
'example': False
},
{
'content': "I'm doing well, thanks for asking! I'm an AI assistant created by Anthropic to be helpful, honest, and harmless.",
'additional_kwargs': {},
'response_metadata': {},
'type': 'ai',
'name': None,
'id': 'run-159b782c-b679-4830-83c6-cef87798fe8b',
'example': False,
'tool_calls': [],
'invalid_tool_calls': [],
'usage_metadata': None
}
]
},
'next': [],
'checkpoint':
{
'thread_id': 'e2496803-ecd5-4e0c-a779-3226296181c2',
'checkpoint_ns': '',
'checkpoint_id': '1ef4a9b8-e6fb-67b1-8001-abd5184439d1'
}
'metadata':
{
'step': 1,
'run_id': '1ef4a9b8-d7da-679a-a45a-872054341df2',
'source': 'loop',
'writes':
{
'agent':
{
'messages': [
{
'id': 'run-159b782c-b679-4830-83c6-cef87798fe8b',
'name': None,
'type': 'ai',
'content': "I'm doing well, thanks for asking! I'm an AI assistant created by Anthropic to be helpful, honest, and harmless.",
'example': False,
'tool_calls': [],
'usage_metadata': None,
'additional_kwargs': {},
'response_metadata': {},
'invalid_tool_calls': []
}
]
}
},
'user_id': None,
'graph_id': 'agent',
'thread_id': 'e2496803-ecd5-4e0c-a779-3226296181c2',
'created_by': 'system',
'assistant_id': 'fe096781-5601-53d2-b2f6-0d3403f7e9ca'},
'created_at': '2024-07-25T15:35:44.184703+00:00',
'parent_config':
{
'thread_id': 'e2496803-ecd5-4e0c-a779-3226296181c2',
'checkpoint_ns': '',
'checkpoint_id': '1ef4a9b8-d80d-6fa7-8000-9300467fad0f'
}
}
```
"""
if checkpoint:
return await self.http.post(
f"/threads/{thread_id}/state/checkpoint",
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
headers=headers,
params=params,
)
elif checkpoint_id:
get_params = {"subgraphs": subgraphs}
if params:
get_params = {**get_params, **dict(params)}
return await self.http.get(
f"/threads/{thread_id}/state/{checkpoint_id}",
params=get_params,
headers=headers,
)
else:
get_params = {"subgraphs": subgraphs}
if params:
get_params = {**get_params, **dict(params)}
return await self.http.get(
f"/threads/{thread_id}/state",
params=get_params,
headers=headers,
)
async def update_state(
self,
thread_id: str,
values: dict[str, Any] | Sequence[dict] | None,
*,
as_node: str | None = None,
checkpoint: Checkpoint | None = None,
checkpoint_id: str | None = None, # deprecated
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> ThreadUpdateStateResponse:
"""Update the state of a thread.
Args:
thread_id: The ID of the thread to update.
values: The values to update the state with.
as_node: Update the state as if this node had just executed.
checkpoint: The checkpoint to update the state of.
checkpoint_id: (deprecated) The checkpoint ID to update the state of.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
Response after updating a thread's state.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024)
response = await client.threads.update_state(
thread_id="my_thread_id",
values={"messages":[{"role": "user", "content": "hello!"}]},
as_node="my_node",
)
print(response)
```
```shell
----------------------------------------------------------------------------------------------------------------------------------------------------------------------
{
'checkpoint': {
'thread_id': 'e2496803-ecd5-4e0c-a779-3226296181c2',
'checkpoint_ns': '',
'checkpoint_id': '1ef4a9b8-e6fb-67b1-8001-abd5184439d1',
'checkpoint_map': {}
}
}
```
"""
payload: dict[str, Any] = {
"values": values,
}
if checkpoint_id:
payload["checkpoint_id"] = checkpoint_id
if checkpoint:
payload["checkpoint"] = checkpoint
if as_node:
payload["as_node"] = as_node
return await self.http.post(
f"/threads/{thread_id}/state", json=payload, headers=headers, params=params
)
async def get_history(
self,
thread_id: str,
*,
limit: int = 10,
before: str | Checkpoint | None = None,
metadata: Mapping[str, Any] | None = None,
checkpoint: Checkpoint | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> list[ThreadState]:
"""Get the state history of a thread.
Args:
thread_id: The ID of the thread to get the state history for.
checkpoint: Return states for this subgraph. If empty defaults to root.
limit: The maximum number of states to return.
before: Return states before this checkpoint.
metadata: Filter states by metadata key-value pairs.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
The state history of the thread.
???+ example "Example Usage"
```python
client = get_client(url="http://localhost:2024)
thread_state = await client.threads.get_history(
thread_id="my_thread_id",
limit=5,
)
```
"""
payload: dict[str, Any] = {
"limit": limit,
}
if before:
payload["before"] = before
if metadata:
payload["metadata"] = metadata
if checkpoint:
payload["checkpoint"] = checkpoint
return await self.http.post(
f"/threads/{thread_id}/history",
json=payload,
headers=headers,
params=params,
)
async def join_stream(
self,
thread_id: str,
*,
last_event_id: str | None = None,
stream_mode: ThreadStreamMode | Sequence[ThreadStreamMode] = "run_modes",
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> AsyncIterator[StreamPart]:
"""Get a stream of events for a thread.
Args:
thread_id: The ID of the thread to get the stream for.
last_event_id: The ID of the last event to get.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
An iterator of stream parts.
???+ example "Example Usage"
```python
for chunk in client.threads.join_stream(
thread_id="my_thread_id",
last_event_id="my_event_id",
):
print(chunk)
```
"""
query_params = {
"stream_mode": stream_mode,
}
if params:
query_params.update(params)
return self.http.stream(
f"/threads/{thread_id}/stream",
"GET",
headers={
**({"Last-Event-ID": last_event_id} if last_event_id else {}),
**(headers or {}),
},
params=query_params,
)

View File

@@ -0,0 +1 @@
"""Shared utilities for async and sync clients."""

View File

@@ -0,0 +1,10 @@
"""Type aliases and constants."""
from __future__ import annotations
TimeoutTypes = (
None
| float
| tuple[float | None, float | None]
| tuple[float | None, float | None, float | None, float | None]
)

View File

@@ -0,0 +1,131 @@
"""Shared utility functions for async and sync clients."""
from __future__ import annotations
import functools
import os
import re
from collections.abc import Mapping
from typing import Any, cast
import httpx
import langgraph_sdk
from langgraph_sdk.schema import RunCreateMetadata
RESERVED_HEADERS = ("x-api-key",)
NOT_PROVIDED = cast(None, object())
def _get_api_key(api_key: str | None = NOT_PROVIDED) -> str | None:
"""Get the API key from the environment.
Precedence:
1. explicit string argument
2. LANGGRAPH_API_KEY (if api_key not provided)
3. LANGSMITH_API_KEY (if api_key not provided)
4. LANGCHAIN_API_KEY (if api_key not provided)
Args:
api_key: The API key to use. Can be:
- A string: use this exact API key
- None: explicitly skip loading from environment
- NOT_PROVIDED (default): auto-load from environment variables
"""
if isinstance(api_key, str):
return api_key
if api_key is NOT_PROVIDED:
# api_key is not explicitly provided, try to load from environment
for prefix in ["LANGGRAPH", "LANGSMITH", "LANGCHAIN"]:
if env := os.getenv(f"{prefix}_API_KEY"):
return env.strip().strip('"').strip("'")
# api_key is explicitly None, don't load from environment
return None
def _get_headers(
api_key: str | None,
custom_headers: Mapping[str, str] | None,
) -> dict[str, str]:
"""Combine api_key and custom user-provided headers."""
custom_headers = custom_headers or {}
for header in RESERVED_HEADERS:
if header in custom_headers:
raise ValueError(f"Cannot set reserved header '{header}'")
headers = {
"User-Agent": f"langgraph-sdk-py/{langgraph_sdk.__version__}",
**custom_headers,
}
resolved_api_key = _get_api_key(api_key)
if resolved_api_key:
headers["x-api-key"] = resolved_api_key
return headers
def _orjson_default(obj: Any) -> Any:
is_class = isinstance(obj, type)
if hasattr(obj, "model_dump") and callable(obj.model_dump):
if is_class:
raise TypeError(
f"Cannot JSON-serialize type object: {obj!r}. Did you mean to pass an instance of the object instead?"
f"\nReceived type: {obj!r}"
)
return obj.model_dump()
elif hasattr(obj, "dict") and callable(obj.dict):
if is_class:
raise TypeError(
f"Cannot JSON-serialize type object: {obj!r}. Did you mean to pass an instance of the object instead?"
f"\nReceived type: {obj!r}"
)
return obj.dict()
elif isinstance(obj, (set, frozenset)):
return list(obj)
else:
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
# Compiled regex pattern for extracting run metadata from Content-Location header
_RUN_METADATA_PATTERN = re.compile(
r"(\/threads\/(?P<thread_id>.+))?\/runs\/(?P<run_id>.+)"
)
def _get_run_metadata_from_response(
response: httpx.Response,
) -> RunCreateMetadata | None:
"""Extract run metadata from the response headers."""
if (content_location := response.headers.get("Content-Location")) and (
match := _RUN_METADATA_PATTERN.search(content_location)
):
return RunCreateMetadata(
run_id=match.group("run_id"),
thread_id=match.group("thread_id") or None,
)
return None
def _provided_vals(d: Mapping[str, Any]) -> dict[str, Any]:
return {k: v for k, v in d.items() if v is not None}
_registered_transports: list[httpx.ASGITransport] = []
# Do not move; this is used in the server.
def configure_loopback_transports(app: Any) -> None:
for transport in _registered_transports:
transport.app = app
@functools.lru_cache(maxsize=1)
def get_asgi_transport() -> type[httpx.ASGITransport]:
try:
from langgraph_api import asgi_transport # type: ignore[unresolved-import]
return asgi_transport.ASGITransport
except ImportError:
# Older versions of the server
return httpx.ASGITransport

View File

@@ -0,0 +1,20 @@
"""Sync client exports."""
from langgraph_sdk._sync.assistants import SyncAssistantsClient
from langgraph_sdk._sync.client import SyncLangGraphClient, get_sync_client
from langgraph_sdk._sync.cron import SyncCronClient
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk._sync.runs import SyncRunsClient
from langgraph_sdk._sync.store import SyncStoreClient
from langgraph_sdk._sync.threads import SyncThreadsClient
__all__ = [
"SyncAssistantsClient",
"SyncCronClient",
"SyncHttpClient",
"SyncLangGraphClient",
"SyncRunsClient",
"SyncStoreClient",
"SyncThreadsClient",
"get_sync_client",
]

View File

@@ -0,0 +1,731 @@
"""Synchronous client for managing assistants in LangGraph."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, Literal, cast, overload
import httpx
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk.schema import (
Assistant,
AssistantSelectField,
AssistantSortBy,
AssistantsSearchResponse,
AssistantVersion,
Config,
Context,
GraphSchema,
Json,
OnConflictBehavior,
QueryParamTypes,
SortOrder,
Subgraphs,
)
class SyncAssistantsClient:
"""Client for managing assistants in LangGraph synchronously.
This class provides methods to interact with assistants, which are versioned configurations of your graph.
???+ example "Example"
```python
client = get_sync_client(url="http://localhost:2024")
assistant = client.assistants.get("assistant_id_123")
```
"""
def __init__(self, http: SyncHttpClient) -> None:
self.http = http
def get(
self,
assistant_id: str,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Assistant:
"""Get an assistant by ID.
Args:
assistant_id: The ID of the assistant to get OR the name of the graph (to use the default assistant).
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
`Assistant` Object.
???+ example "Example Usage"
```python
assistant = client.assistants.get(
assistant_id="my_assistant_id"
)
print(assistant)
```
```shell
----------------------------------------------------
{
'assistant_id': 'my_assistant_id',
'graph_id': 'agent',
'created_at': '2024-06-25T17:10:33.109781+00:00',
'updated_at': '2024-06-25T17:10:33.109781+00:00',
'config': {},
'context': {},
'metadata': {'created_by': 'system'}
}
```
"""
return self.http.get(
f"/assistants/{assistant_id}", headers=headers, params=params
)
def get_graph(
self,
assistant_id: str,
*,
xray: int | bool = False,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> dict[str, list[dict[str, Any]]]:
"""Get the graph of an assistant by ID.
Args:
assistant_id: The ID of the assistant to get the graph of.
xray: Include graph representation of subgraphs. If an integer value is provided, only subgraphs with a depth less than or equal to the value will be included.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
The graph information for the assistant in JSON format.
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:2024")
graph_info = client.assistants.get_graph(
assistant_id="my_assistant_id"
)
print(graph_info)
--------------------------------------------------------------------------------------------------------------------------
{
'nodes':
[
{'id': '__start__', 'type': 'schema', 'data': '__start__'},
{'id': '__end__', 'type': 'schema', 'data': '__end__'},
{'id': 'agent','type': 'runnable','data': {'id': ['langgraph', 'utils', 'RunnableCallable'],'name': 'agent'}},
],
'edges':
[
{'source': '__start__', 'target': 'agent'},
{'source': 'agent','target': '__end__'}
]
}
```
"""
query_params = {"xray": xray}
if params:
query_params.update(params)
return self.http.get(
f"/assistants/{assistant_id}/graph", params=query_params, headers=headers
)
def get_schemas(
self,
assistant_id: str,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> GraphSchema:
"""Get the schemas of an assistant by ID.
Args:
assistant_id: The ID of the assistant to get the schema of.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
GraphSchema: The graph schema for the assistant.
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:2024")
schema = client.assistants.get_schemas(
assistant_id="my_assistant_id"
)
print(schema)
```
```shell
----------------------------------------------------------------------------------------------------------------------------
{
'graph_id': 'agent',
'state_schema':
{
'title': 'LangGraphInput',
'$ref': '#/definitions/AgentState',
'definitions':
{
'BaseMessage':
{
'title': 'BaseMessage',
'description': 'Base abstract Message class. Messages are the inputs and outputs of ChatModels.',
'type': 'object',
'properties':
{
'content':
{
'title': 'Content',
'anyOf': [
{'type': 'string'},
{'type': 'array','items': {'anyOf': [{'type': 'string'}, {'type': 'object'}]}}
]
},
'additional_kwargs':
{
'title': 'Additional Kwargs',
'type': 'object'
},
'response_metadata':
{
'title': 'Response Metadata',
'type': 'object'
},
'type':
{
'title': 'Type',
'type': 'string'
},
'name':
{
'title': 'Name',
'type': 'string'
},
'id':
{
'title': 'Id',
'type': 'string'
}
},
'required': ['content', 'type']
},
'AgentState':
{
'title': 'AgentState',
'type': 'object',
'properties':
{
'messages':
{
'title': 'Messages',
'type': 'array',
'items': {'$ref': '#/definitions/BaseMessage'}
}
},
'required': ['messages']
}
}
},
'config_schema':
{
'title': 'Configurable',
'type': 'object',
'properties':
{
'model_name':
{
'title': 'Model Name',
'enum': ['anthropic', 'openai'],
'type': 'string'
}
}
},
'context_schema':
{
'title': 'Context',
'type': 'object',
'properties':
{
'model_name':
{
'title': 'Model Name',
'enum': ['anthropic', 'openai'],
'type': 'string'
}
}
}
}
```
"""
return self.http.get(
f"/assistants/{assistant_id}/schemas", headers=headers, params=params
)
def get_subgraphs(
self,
assistant_id: str,
namespace: str | None = None,
recurse: bool = False,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Subgraphs:
"""Get the schemas of an assistant by ID.
Args:
assistant_id: The ID of the assistant to get the schema of.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
Subgraphs: The graph schema for the assistant.
"""
get_params = {"recurse": recurse}
if params:
get_params = {**get_params, **dict(params)}
if namespace is not None:
return self.http.get(
f"/assistants/{assistant_id}/subgraphs/{namespace}",
params=get_params,
headers=headers,
)
else:
return self.http.get(
f"/assistants/{assistant_id}/subgraphs",
params=get_params,
headers=headers,
)
def create(
self,
graph_id: str | None,
config: Config | None = None,
*,
context: Context | None = None,
metadata: Json = None,
assistant_id: str | None = None,
if_exists: OnConflictBehavior | None = None,
name: str | None = None,
headers: Mapping[str, str] | None = None,
description: str | None = None,
params: QueryParamTypes | None = None,
) -> Assistant:
"""Create a new assistant.
Useful when graph is configurable and you want to create different assistants based on different configurations.
Args:
graph_id: The ID of the graph the assistant should use. The graph ID is normally set in your langgraph.json configuration.
config: Configuration to use for the graph.
context: Static context to add to the assistant.
!!! version-added "Added in version 0.6.0"
metadata: Metadata to add to assistant.
assistant_id: Assistant ID to use, will default to a random UUID if not provided.
if_exists: How to handle duplicate creation. Defaults to 'raise' under the hood.
Must be either 'raise' (raise error if duplicate), or 'do_nothing' (return existing assistant).
name: The name of the assistant. Defaults to 'Untitled' under the hood.
headers: Optional custom headers to include with the request.
description: Optional description of the assistant.
The description field is available for langgraph-api server version>=0.0.45
params: Optional query parameters to include with the request.
Returns:
The created assistant.
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:2024")
assistant = client.assistants.create(
graph_id="agent",
context={"model_name": "openai"},
metadata={"number":1},
assistant_id="my-assistant-id",
if_exists="do_nothing",
name="my_name"
)
```
"""
payload: dict[str, Any] = {
"graph_id": graph_id,
}
if config:
payload["config"] = config
if context:
payload["context"] = context
if metadata:
payload["metadata"] = metadata
if assistant_id:
payload["assistant_id"] = assistant_id
if if_exists:
payload["if_exists"] = if_exists
if name:
payload["name"] = name
if description:
payload["description"] = description
return self.http.post(
"/assistants", json=payload, headers=headers, params=params
)
def update(
self,
assistant_id: str,
*,
graph_id: str | None = None,
config: Config | None = None,
context: Context | None = None,
metadata: Json = None,
name: str | None = None,
headers: Mapping[str, str] | None = None,
description: str | None = None,
params: QueryParamTypes | None = None,
) -> Assistant:
"""Update an assistant.
Use this to point to a different graph, update the configuration, or change the metadata of an assistant.
Args:
assistant_id: Assistant to update.
graph_id: The ID of the graph the assistant should use.
The graph ID is normally set in your langgraph.json configuration. If `None`, assistant will keep pointing to same graph.
config: Configuration to use for the graph.
context: Static context to add to the assistant.
!!! version-added "Added in version 0.6.0"
metadata: Metadata to merge with existing assistant metadata.
name: The new name for the assistant.
headers: Optional custom headers to include with the request.
description: Optional description of the assistant.
The description field is available for langgraph-api server version>=0.0.45
Returns:
The updated assistant.
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:2024")
assistant = client.assistants.update(
assistant_id='e280dad7-8618-443f-87f1-8e41841c180f',
graph_id="other-graph",
context={"model_name": "anthropic"},
metadata={"number":2}
)
```
"""
payload: dict[str, Any] = {}
if graph_id:
payload["graph_id"] = graph_id
if config is not None:
payload["config"] = config
if context is not None:
payload["context"] = context
if metadata:
payload["metadata"] = metadata
if name:
payload["name"] = name
if description:
payload["description"] = description
return self.http.patch(
f"/assistants/{assistant_id}",
json=payload,
headers=headers,
params=params,
)
def delete(
self,
assistant_id: str,
*,
delete_threads: bool = False,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> None:
"""Delete an assistant.
Args:
assistant_id: The assistant ID to delete.
delete_threads: If true, delete all threads with `metadata.assistant_id`
matching this assistant, along with runs and checkpoints belonging to
those threads.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
`None`
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:2024")
client.assistants.delete(
assistant_id="my_assistant_id"
)
```
"""
query_params: dict[str, Any] = {}
if delete_threads:
query_params["delete_threads"] = True
if params:
query_params.update(params)
self.http.delete(
f"/assistants/{assistant_id}",
headers=headers,
params=query_params or None,
)
@overload
def search(
self,
*,
metadata: Json = None,
graph_id: str | None = None,
name: str | None = None,
limit: int = 10,
offset: int = 0,
sort_by: AssistantSortBy | None = None,
sort_order: SortOrder | None = None,
select: list[AssistantSelectField] | None = None,
response_format: Literal["object"],
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> AssistantsSearchResponse: ...
@overload
def search(
self,
*,
metadata: Json = None,
graph_id: str | None = None,
name: str | None = None,
limit: int = 10,
offset: int = 0,
sort_by: AssistantSortBy | None = None,
sort_order: SortOrder | None = None,
select: list[AssistantSelectField] | None = None,
response_format: Literal["array"] = "array",
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> list[Assistant]: ...
def search(
self,
*,
metadata: Json = None,
graph_id: str | None = None,
name: str | None = None,
limit: int = 10,
offset: int = 0,
sort_by: AssistantSortBy | None = None,
sort_order: SortOrder | None = None,
select: list[AssistantSelectField] | None = None,
response_format: Literal["array", "object"] = "array",
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> AssistantsSearchResponse | list[Assistant]:
"""Search for assistants.
Args:
metadata: Metadata to filter by. Exact match filter for each KV pair.
graph_id: The ID of the graph to filter by.
The graph ID is normally set in your langgraph.json configuration.
name: The name of the assistant to filter by.
The filtering logic will match assistants where 'name' is a substring (case insensitive) of the assistant name.
limit: The maximum number of results to return.
offset: The number of results to skip.
sort_by: The field to sort by.
sort_order: The order to sort by.
select: Specific assistant fields to include in the response.
response_format: Controls the response shape. Use `"array"` (default)
to return a bare list of assistants, or `"object"` to return
a mapping containing assistants plus pagination metadata.
Defaults to "array", though this default will be changed to "object" in a future release.
headers: Optional custom headers to include with the request.
Returns:
A list of assistants (when `response_format="array"`) or a mapping
with the assistants and the next pagination cursor (when
`response_format="object"`).
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:2024")
response = client.assistants.search(
metadata = {"name":"my_name"},
graph_id="my_graph_id",
limit=5,
offset=5,
response_format="object",
)
assistants = response["assistants"]
next_cursor = response["next"]
```
"""
if response_format not in ("array", "object"):
raise ValueError("response_format must be 'array' or 'object'")
payload: dict[str, Any] = {
"limit": limit,
"offset": offset,
}
if metadata:
payload["metadata"] = metadata
if graph_id:
payload["graph_id"] = graph_id
if name:
payload["name"] = name
if sort_by:
payload["sort_by"] = sort_by
if sort_order:
payload["sort_order"] = sort_order
if select:
payload["select"] = select
next_cursor: str | None = None
def capture_pagination(response: httpx.Response) -> None:
nonlocal next_cursor
next_cursor = response.headers.get("X-Pagination-Next")
assistants = cast(
list[Assistant],
self.http.post(
"/assistants/search",
json=payload,
headers=headers,
params=params,
on_response=capture_pagination if response_format == "object" else None,
),
)
if response_format == "object":
return {"assistants": assistants, "next": next_cursor}
return assistants
def count(
self,
*,
metadata: Json = None,
graph_id: str | None = None,
name: str | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> int:
"""Count assistants matching filters.
Args:
metadata: Metadata to filter by. Exact match for each key/value.
graph_id: Optional graph id to filter by.
name: Optional name to filter by.
The filtering logic will match assistants where 'name' is a substring (case insensitive) of the assistant name.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
int: Number of assistants matching the criteria.
"""
payload: dict[str, Any] = {}
if metadata:
payload["metadata"] = metadata
if graph_id:
payload["graph_id"] = graph_id
if name:
payload["name"] = name
return self.http.post(
"/assistants/count", json=payload, headers=headers, params=params
)
def get_versions(
self,
assistant_id: str,
metadata: Json = None,
limit: int = 10,
offset: int = 0,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> list[AssistantVersion]:
"""List all versions of an assistant.
Args:
assistant_id: The assistant ID to get versions for.
metadata: Metadata to filter versions by. Exact match filter for each KV pair.
limit: The maximum number of versions to return.
offset: The number of versions to skip.
headers: Optional custom headers to include with the request.
Returns:
A list of assistants.
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:2024")
assistant_versions = client.assistants.get_versions(
assistant_id="my_assistant_id"
)
```
"""
payload: dict[str, Any] = {
"limit": limit,
"offset": offset,
}
if metadata:
payload["metadata"] = metadata
return self.http.post(
f"/assistants/{assistant_id}/versions",
json=payload,
headers=headers,
params=params,
)
def set_latest(
self,
assistant_id: str,
version: int,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Assistant:
"""Change the version of an assistant.
Args:
assistant_id: The assistant ID to delete.
version: The version to change to.
headers: Optional custom headers to include with the request.
Returns:
`Assistant` Object.
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:2024")
new_version_assistant = client.assistants.set_latest(
assistant_id="my_assistant_id",
version=3
)
```
"""
payload: dict[str, Any] = {"version": version}
return self.http.post(
f"/assistants/{assistant_id}/latest",
json=payload,
headers=headers,
params=params,
)

View File

@@ -0,0 +1,127 @@
"""Sync LangGraph client."""
from __future__ import annotations
from collections.abc import Mapping
from types import TracebackType
import httpx
from langgraph_sdk._shared.types import TimeoutTypes
from langgraph_sdk._shared.utilities import NOT_PROVIDED, _get_headers
from langgraph_sdk._sync.assistants import SyncAssistantsClient
from langgraph_sdk._sync.cron import SyncCronClient
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk._sync.runs import SyncRunsClient
from langgraph_sdk._sync.store import SyncStoreClient
from langgraph_sdk._sync.threads import SyncThreadsClient
def get_sync_client(
*,
url: str | None = None,
api_key: str | None = NOT_PROVIDED,
headers: Mapping[str, str] | None = None,
timeout: TimeoutTypes | None = None,
) -> SyncLangGraphClient:
"""Get a synchronous LangGraphClient instance.
Args:
url: The URL of the LangGraph API.
api_key: API key for authentication. Can be:
- A string: use this exact API key
- `None`: explicitly skip loading from environment variables
- Not provided (default): auto-load from environment in this order:
1. `LANGGRAPH_API_KEY`
2. `LANGSMITH_API_KEY`
3. `LANGCHAIN_API_KEY`
headers: Optional custom headers
timeout: Optional timeout configuration for the HTTP client.
Accepts an httpx.Timeout instance, a float (seconds), or a tuple of timeouts.
Tuple format is (connect, read, write, pool)
If not provided, defaults to connect=5s, read=300s, write=300s, and pool=5s.
Returns:
SyncLangGraphClient: The top-level synchronous client for accessing AssistantsClient,
ThreadsClient, RunsClient, and CronClient.
???+ example "Example"
```python
from langgraph_sdk import get_sync_client
# get top-level synchronous LangGraphClient
client = get_sync_client(url="http://localhost:8123")
# example usage: client.<model>.<method_name>()
assistant = client.assistants.get(assistant_id="some_uuid")
```
???+ example "Skip auto-loading API key from environment:"
```python
from langgraph_sdk import get_sync_client
# Don't load API key from environment variables
client = get_sync_client(
url="http://localhost:8123",
api_key=None
)
```
"""
if url is None:
url = "http://localhost:8123"
transport = httpx.HTTPTransport(retries=5)
client = httpx.Client(
base_url=url,
transport=transport,
timeout=(
httpx.Timeout(timeout) # type: ignore[arg-type]
if timeout is not None
else httpx.Timeout(connect=5, read=300, write=300, pool=5)
),
headers=_get_headers(api_key, headers),
)
return SyncLangGraphClient(client)
class SyncLangGraphClient:
"""Synchronous client for interacting with the LangGraph API.
This class provides synchronous access to LangGraph API endpoints for managing
assistants, threads, runs, cron jobs, and data storage.
???+ example "Example"
```python
client = get_sync_client(url="http://localhost:2024")
assistant = client.assistants.get("asst_123")
```
"""
def __init__(self, client: httpx.Client) -> None:
self.http = SyncHttpClient(client)
self.assistants = SyncAssistantsClient(self.http)
self.threads = SyncThreadsClient(self.http)
self.runs = SyncRunsClient(self.http)
self.crons = SyncCronClient(self.http)
self.store = SyncStoreClient(self.http)
def __enter__(self) -> SyncLangGraphClient:
"""Enter the sync context manager."""
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
"""Exit the sync context manager."""
self.close()
def close(self) -> None:
"""Close the underlying HTTP client."""
if hasattr(self, "http"):
self.http.client.close()

View File

@@ -0,0 +1,498 @@
"""Synchronous cron client for LangGraph SDK."""
from __future__ import annotations
import warnings
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import Any
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk.schema import (
All,
Config,
Context,
Cron,
CronSelectField,
CronSortBy,
Durability,
Input,
OnCompletionBehavior,
QueryParamTypes,
Run,
SortOrder,
StreamMode,
)
class SyncCronClient:
"""Synchronous client for managing cron jobs in LangGraph.
This class provides methods to create and manage scheduled tasks (cron jobs) for automated graph executions.
???+ example "Example"
```python
client = get_sync_client(url="http://localhost:8123")
cron_job = client.crons.create_for_thread(thread_id="thread_123", assistant_id="asst_456", schedule="0 * * * *")
```
!!! note "Feature Availability"
The crons client functionality is not supported on all licenses.
Please check the relevant license documentation for the most up-to-date
details on feature availability.
"""
def __init__(self, http_client: SyncHttpClient) -> None:
self.http = http_client
def create_for_thread(
self,
thread_id: str,
assistant_id: str,
*,
schedule: str,
input: Input | None = None,
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
checkpoint_during: bool | None = None, # deprecated
interrupt_before: All | list[str] | None = None,
interrupt_after: All | list[str] | None = None,
webhook: str | None = None,
multitask_strategy: str | None = None,
end_time: datetime | None = None,
enabled: bool | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
durability: Durability | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Run:
"""Create a cron job for a thread.
Args:
thread_id: the thread ID to run the cron job on.
assistant_id: The assistant ID or graph name to use for the cron job.
If using graph name, will default to first assistant created from that graph.
schedule: The cron schedule to execute this job on.
Schedules are interpreted in UTC.
input: The input to the graph.
metadata: Metadata to assign to the cron job runs.
config: The configuration for the assistant.
context: Static context to add to the assistant.
!!! version-added "Added in version 0.6.0"
checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption).
interrupt_before: Nodes to interrupt immediately before they get executed.
interrupt_after: Nodes to Nodes to interrupt immediately after they get executed.
webhook: Webhook to call after LangGraph API call is done.
multitask_strategy: Multitask strategy to use.
Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely.
enabled: Whether the cron job is enabled. By default, it is considered enabled.
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
headers: Optional custom headers to include with the request.
Returns:
The cron `Run`.
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:8123")
cron_run = client.crons.create_for_thread(
thread_id="my-thread-id",
assistant_id="agent",
schedule="27 15 * * *",
input={"messages": [{"role": "user", "content": "hello!"}]},
metadata={"name":"my_run"},
context={"model_name": "openai"},
interrupt_before=["node_to_stop_before_1","node_to_stop_before_2"],
interrupt_after=["node_to_stop_after_1","node_to_stop_after_2"],
webhook="https://my.fake.webhook.com",
multitask_strategy="interrupt",
enabled=True
)
```
"""
if checkpoint_during is not None:
warnings.warn(
"`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.",
DeprecationWarning,
stacklevel=2,
)
payload = {
"schedule": schedule,
"input": input,
"config": config,
"metadata": metadata,
"context": context,
"assistant_id": assistant_id,
"interrupt_before": interrupt_before,
"interrupt_after": interrupt_after,
"checkpoint_during": checkpoint_during,
"webhook": webhook,
"multitask_strategy": multitask_strategy,
"end_time": end_time.isoformat() if end_time else None,
"enabled": enabled,
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
"durability": durability,
}
payload = {k: v for k, v in payload.items() if v is not None}
return self.http.post(
f"/threads/{thread_id}/runs/crons",
json=payload,
headers=headers,
params=params,
)
def create(
self,
assistant_id: str,
*,
schedule: str,
input: Input | None = None,
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
checkpoint_during: bool | None = None, # deprecated
interrupt_before: All | list[str] | None = None,
interrupt_after: All | list[str] | None = None,
webhook: str | None = None,
on_run_completed: OnCompletionBehavior | None = None,
multitask_strategy: str | None = None,
end_time: datetime | None = None,
enabled: bool | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
durability: Durability | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Run:
"""Create a cron run.
Args:
assistant_id: The assistant ID or graph name to use for the cron job.
If using graph name, will default to first assistant created from that graph.
schedule: The cron schedule to execute this job on.
Schedules are interpreted in UTC.
input: The input to the graph.
metadata: Metadata to assign to the cron job runs.
config: The configuration for the assistant.
context: Static context to add to the assistant.
!!! version-added "Added in version 0.6.0"
checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption).
interrupt_before: Nodes to interrupt immediately before they get executed.
interrupt_after: Nodes to Nodes to interrupt immediately after they get executed.
webhook: Webhook to call after LangGraph API call is done.
on_run_completed: What to do with the thread after the run completes.
Must be one of 'delete' (default) or 'keep'. 'delete' removes the thread
after execution. 'keep' creates a new thread for each execution but does not
clean them up. Clients are responsible for cleaning up kept threads.
multitask_strategy: Multitask strategy to use.
Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely.
enabled: Whether the cron job is enabled. By default, it is considered enabled.
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
headers: Optional custom headers to include with the request.
Returns:
The cron `Run`.
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:8123")
cron_run = client.crons.create(
assistant_id="agent",
schedule="27 15 * * *",
input={"messages": [{"role": "user", "content": "hello!"}]},
metadata={"name":"my_run"},
context={"model_name": "openai"},
checkpoint_during=True,
interrupt_before=["node_to_stop_before_1","node_to_stop_before_2"],
interrupt_after=["node_to_stop_after_1","node_to_stop_after_2"],
webhook="https://my.fake.webhook.com",
multitask_strategy="interrupt",
enabled=True
)
```
"""
if checkpoint_during is not None:
warnings.warn(
"`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.",
DeprecationWarning,
stacklevel=2,
)
payload = {
"schedule": schedule,
"input": input,
"config": config,
"metadata": metadata,
"context": context,
"assistant_id": assistant_id,
"interrupt_before": interrupt_before,
"interrupt_after": interrupt_after,
"webhook": webhook,
"checkpoint_during": checkpoint_during,
"on_run_completed": on_run_completed,
"multitask_strategy": multitask_strategy,
"end_time": end_time.isoformat() if end_time else None,
"enabled": enabled,
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
"durability": durability,
}
payload = {k: v for k, v in payload.items() if v is not None}
return self.http.post(
"/runs/crons", json=payload, headers=headers, params=params
)
def delete(
self,
cron_id: str,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> None:
"""Delete a cron.
Args:
cron_id: The cron ID to delete.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
`None`
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:8123")
client.crons.delete(
cron_id="cron_to_delete"
)
```
"""
self.http.delete(f"/runs/crons/{cron_id}", headers=headers, params=params)
def update(
self,
cron_id: str,
*,
schedule: str | None = None,
end_time: datetime | None = None,
input: Input | None = None,
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
webhook: str | None = None,
interrupt_before: All | list[str] | None = None,
interrupt_after: All | list[str] | None = None,
on_run_completed: OnCompletionBehavior | None = None,
enabled: bool | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
durability: Durability | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Cron:
"""Update a cron job by ID.
Args:
cron_id: The cron ID to update.
schedule: The cron schedule to execute this job on.
Schedules are interpreted in UTC.
end_time: The end date to stop running the cron.
input: The input to the graph.
metadata: Metadata to assign to the cron job runs.
config: The configuration for the assistant.
context: Static context added to the assistant.
webhook: Webhook to call after LangGraph API call is done.
interrupt_before: Nodes to interrupt immediately before they get executed.
interrupt_after: Nodes to interrupt immediately after they get executed.
on_run_completed: What to do with the thread after the run completes.
Must be one of 'delete' or 'keep'. 'delete' removes the thread
after execution. 'keep' creates a new thread for each execution but does not
clean them up.
enabled: Enable or disable the cron job.
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
The updated cron job.
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:8123")
updated_cron = client.crons.update(
cron_id="1ef3cefa-4c09-6926-96d0-3dc97fd5e39b",
schedule="0 10 * * *",
enabled=False,
)
```
"""
payload = {
"schedule": schedule,
"end_time": end_time.isoformat() if end_time else None,
"input": input,
"metadata": metadata,
"config": config,
"context": context,
"webhook": webhook,
"interrupt_before": interrupt_before,
"interrupt_after": interrupt_after,
"on_run_completed": on_run_completed,
"enabled": enabled,
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
"durability": durability,
}
payload = {k: v for k, v in payload.items() if v is not None}
return self.http.patch(
f"/runs/crons/{cron_id}",
json=payload,
headers=headers,
params=params,
)
def search(
self,
*,
assistant_id: str | None = None,
thread_id: str | None = None,
enabled: bool | None = None,
limit: int = 10,
offset: int = 0,
sort_by: CronSortBy | None = None,
sort_order: SortOrder | None = None,
select: list[CronSelectField] | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> list[Cron]:
"""Get a list of cron jobs.
Args:
assistant_id: The assistant ID or graph name to search for.
thread_id: the thread ID to search for.
enabled: Whether the cron job is enabled.
limit: The maximum number of results to return.
offset: The number of results to skip.
headers: Optional custom headers to include with the request.
Returns:
The list of cron jobs returned by the search,
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:8123")
cron_jobs = client.crons.search(
assistant_id="my_assistant_id",
thread_id="my_thread_id",
enabled=True,
limit=5,
offset=5,
)
print(cron_jobs)
```
```shell
----------------------------------------------------------
[
{
'cron_id': '1ef3cefa-4c09-6926-96d0-3dc97fd5e39b',
'assistant_id': 'my_assistant_id',
'thread_id': 'my_thread_id',
'user_id': None,
'payload':
{
'input': {'start_time': ''},
'schedule': '4 * * * *',
'assistant_id': 'my_assistant_id'
},
'schedule': '4 * * * *',
'next_run_date': '2024-07-25T17:04:00+00:00',
'end_time': None,
'created_at': '2024-07-08T06:02:23.073257+00:00',
'updated_at': '2024-07-08T06:02:23.073257+00:00'
}
]
```
"""
payload = {
"assistant_id": assistant_id,
"thread_id": thread_id,
"enabled": enabled,
"limit": limit,
"offset": offset,
}
if sort_by:
payload["sort_by"] = sort_by
if sort_order:
payload["sort_order"] = sort_order
if select:
payload["select"] = select
payload = {k: v for k, v in payload.items() if v is not None}
return self.http.post(
"/runs/crons/search", json=payload, headers=headers, params=params
)
def count(
self,
*,
assistant_id: str | None = None,
thread_id: str | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> int:
"""Count cron jobs matching filters.
Args:
assistant_id: Assistant ID to filter by.
thread_id: Thread ID to filter by.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
int: Number of crons matching the criteria.
"""
payload: dict[str, Any] = {}
if assistant_id:
payload["assistant_id"] = assistant_id
if thread_id:
payload["thread_id"] = thread_id
return self.http.post(
"/runs/crons/count", json=payload, headers=headers, params=params
)

View File

@@ -0,0 +1,296 @@
"""Synchronous HTTP client for LangGraph API."""
from __future__ import annotations
import logging
import sys
import warnings
from collections.abc import Callable, Iterator, Mapping
from typing import Any, cast
import httpx
import orjson
from langgraph_sdk._shared.utilities import _orjson_default
from langgraph_sdk.errors import _raise_for_status_typed
from langgraph_sdk.schema import QueryParamTypes, StreamPart
from langgraph_sdk.sse import SSEDecoder, iter_lines_raw
logger = logging.getLogger(__name__)
class SyncHttpClient:
"""Handle synchronous requests to the LangGraph API.
Provides error messaging and content handling enhancements above the
underlying httpx client, mirroring the interface of [HttpClient](#HttpClient)
but for sync usage.
Attributes:
client (httpx.Client): Underlying HTTPX sync client.
"""
def __init__(self, client: httpx.Client) -> None:
self.client = client
def get(
self,
path: str,
*,
params: QueryParamTypes | None = None,
headers: Mapping[str, str] | None = None,
on_response: Callable[[httpx.Response], None] | None = None,
) -> Any:
"""Send a `GET` request."""
r = self.client.get(path, params=params, headers=headers)
if on_response:
on_response(r)
_raise_for_status_typed(r)
return _decode_json(r)
def post(
self,
path: str,
*,
json: dict[str, Any] | list | None,
params: QueryParamTypes | None = None,
headers: Mapping[str, str] | None = None,
on_response: Callable[[httpx.Response], None] | None = None,
) -> Any:
"""Send a `POST` request."""
if json is not None:
request_headers, content = _encode_json(json)
else:
request_headers, content = {}, b""
if headers:
request_headers.update(headers)
r = self.client.post(
path, headers=request_headers, content=content, params=params
)
if on_response:
on_response(r)
_raise_for_status_typed(r)
return _decode_json(r)
def put(
self,
path: str,
*,
json: dict,
params: QueryParamTypes | None = None,
headers: Mapping[str, str] | None = None,
on_response: Callable[[httpx.Response], None] | None = None,
) -> Any:
"""Send a `PUT` request."""
request_headers, content = _encode_json(json)
if headers:
request_headers.update(headers)
r = self.client.put(
path, headers=request_headers, content=content, params=params
)
if on_response:
on_response(r)
_raise_for_status_typed(r)
return _decode_json(r)
def patch(
self,
path: str,
*,
json: dict,
params: QueryParamTypes | None = None,
headers: Mapping[str, str] | None = None,
on_response: Callable[[httpx.Response], None] | None = None,
) -> Any:
"""Send a `PATCH` request."""
request_headers, content = _encode_json(json)
if headers:
request_headers.update(headers)
r = self.client.patch(
path, headers=request_headers, content=content, params=params
)
if on_response:
on_response(r)
_raise_for_status_typed(r)
return _decode_json(r)
def delete(
self,
path: str,
*,
json: Any | None = None,
params: QueryParamTypes | None = None,
headers: Mapping[str, str] | None = None,
on_response: Callable[[httpx.Response], None] | None = None,
) -> None:
"""Send a `DELETE` request."""
r = self.client.request(
"DELETE", path, json=json, params=params, headers=headers
)
if on_response:
on_response(r)
_raise_for_status_typed(r)
def request_reconnect(
self,
path: str,
method: str,
*,
json: dict[str, Any] | None = None,
params: QueryParamTypes | None = None,
headers: Mapping[str, str] | None = None,
on_response: Callable[[httpx.Response], None] | None = None,
reconnect_limit: int = 5,
) -> Any:
"""Send a request that automatically reconnects to Location header."""
request_headers, content = _encode_json(json)
if headers:
request_headers.update(headers)
with self.client.stream(
method, path, headers=request_headers, content=content, params=params
) as r:
if on_response:
on_response(r)
try:
r.raise_for_status()
except httpx.HTTPStatusError as e:
body = r.read().decode()
if sys.version_info >= (3, 11):
e.add_note(body)
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
loc = r.headers.get("location")
if reconnect_limit <= 0 or not loc:
return _decode_json(r)
try:
return _decode_json(r)
except httpx.HTTPError:
warnings.warn(
f"Request failed, attempting reconnect to Location: {loc}",
stacklevel=2,
)
r.close()
return self.request_reconnect(
loc,
"GET",
headers=request_headers,
# don't pass on_response so it's only called once
reconnect_limit=reconnect_limit - 1,
)
def stream(
self,
path: str,
method: str,
*,
json: dict[str, Any] | None = None,
params: QueryParamTypes | None = None,
headers: Mapping[str, str] | None = None,
on_response: Callable[[httpx.Response], None] | None = None,
) -> Iterator[StreamPart]:
"""Stream the results of a request using SSE."""
if json is not None:
request_headers, content = _encode_json(json)
else:
request_headers, content = {}, None
request_headers["Accept"] = "text/event-stream"
request_headers["Cache-Control"] = "no-store"
if headers:
request_headers.update(headers)
reconnect_headers = {
key: value
for key, value in request_headers.items()
if key.lower() not in {"content-length", "content-type"}
}
last_event_id: str | None = None
reconnect_path: str | None = None
reconnect_attempts = 0
max_reconnect_attempts = 5
while True:
current_headers = dict(
request_headers if reconnect_path is None else reconnect_headers
)
if last_event_id is not None:
current_headers["Last-Event-ID"] = last_event_id
current_method = method if reconnect_path is None else "GET"
current_content = content if reconnect_path is None else None
current_params = params if reconnect_path is None else None
retry = False
with self.client.stream(
current_method,
reconnect_path or path,
headers=current_headers,
content=current_content,
params=current_params,
) as res:
if reconnect_path is None and on_response:
on_response(res)
# check status
_raise_for_status_typed(res)
# check content type
content_type = res.headers.get("content-type", "").partition(";")[0]
if "text/event-stream" not in content_type:
raise httpx.TransportError(
"Expected response header Content-Type to contain 'text/event-stream', "
f"got {content_type!r}"
)
reconnect_location = res.headers.get("location")
if reconnect_location:
reconnect_path = reconnect_location
decoder = SSEDecoder()
try:
for line in iter_lines_raw(res):
sse = decoder.decode(cast(bytes, line).rstrip(b"\n"))
if sse is not None:
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or sse.data is not None:
yield sse
except httpx.HTTPError:
# httpx.TransportError inherits from HTTPError, so transient
# disconnects during streaming land here.
if reconnect_path is None:
raise
retry = True
else:
if sse := decoder.decode(b""):
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or sse.data is not None:
# See async stream implementation for rationale on
# skipping empty flush events.
yield sse
if retry:
reconnect_attempts += 1
if reconnect_attempts > max_reconnect_attempts:
raise httpx.TransportError(
"Exceeded maximum SSE reconnection attempts"
)
continue
break
def _encode_json(json: Any) -> tuple[dict[str, str], bytes]:
body = orjson.dumps(
json,
_orjson_default,
orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS,
)
content_length = str(len(body))
content_type = "application/json"
headers = {"Content-Length": content_length, "Content-Type": content_type}
return headers, body
def _decode_json(r: httpx.Response) -> Any:
body = r.read()
return orjson.loads(body) if body else None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,313 @@
"""Synchronous store client for LangGraph SDK."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any, Literal
from langgraph_sdk._shared.utilities import _provided_vals
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk.schema import (
Item,
ListNamespaceResponse,
QueryParamTypes,
SearchItemsResponse,
)
class SyncStoreClient:
"""A client for synchronous operations on a key-value store.
Provides methods to interact with a remote key-value store, allowing
storage and retrieval of items within namespaced hierarchies.
???+ example "Example"
```python
client = get_sync_client(url="http://localhost:2024"))
client.store.put_item(["users", "profiles"], "user123", {"name": "Alice", "age": 30})
```
"""
def __init__(self, http: SyncHttpClient) -> None:
self.http = http
def put_item(
self,
namespace: Sequence[str],
/,
key: str,
value: Mapping[str, Any],
index: Literal[False] | list[str] | None = None,
ttl: int | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> None:
"""Store or update an item.
Args:
namespace: A list of strings representing the namespace path.
key: The unique identifier for the item within the namespace.
value: A dictionary containing the item's data.
index: Controls search indexing - None (use defaults), False (disable), or list of field paths to index.
ttl: Optional time-to-live in minutes for the item, or None for no expiration.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
`None`
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:8123")
client.store.put_item(
["documents", "user123"],
key="item456",
value={"title": "My Document", "content": "Hello World"}
)
```
"""
for label in namespace:
if "." in label:
raise ValueError(
f"Invalid namespace label '{label}'. Namespace labels cannot contain periods ('.')."
)
payload = {
"namespace": namespace,
"key": key,
"value": value,
"index": index,
"ttl": ttl,
}
self.http.put(
"/store/items", json=_provided_vals(payload), headers=headers, params=params
)
def get_item(
self,
namespace: Sequence[str],
/,
key: str,
*,
refresh_ttl: bool | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Item:
"""Retrieve a single item.
Args:
key: The unique identifier for the item.
namespace: Optional list of strings representing the namespace path.
refresh_ttl: Whether to refresh the TTL on this read operation. If `None`, uses the store's default behavior.
headers: Optional custom headers to include with the request.
Returns:
The retrieved item.
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:8123")
item = client.store.get_item(
["documents", "user123"],
key="item456",
)
print(item)
```
```shell
----------------------------------------------------------------
{
'namespace': ['documents', 'user123'],
'key': 'item456',
'value': {'title': 'My Document', 'content': 'Hello World'},
'created_at': '2024-07-30T12:00:00Z',
'updated_at': '2024-07-30T12:00:00Z'
}
```
"""
for label in namespace:
if "." in label:
raise ValueError(
f"Invalid namespace label '{label}'. Namespace labels cannot contain periods ('.')."
)
query_params = {"key": key, "namespace": ".".join(namespace)}
if refresh_ttl is not None:
query_params["refresh_ttl"] = refresh_ttl
if params:
query_params.update(params)
return self.http.get("/store/items", params=query_params, headers=headers)
def delete_item(
self,
namespace: Sequence[str],
/,
key: str,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> None:
"""Delete an item.
Args:
key: The unique identifier for the item.
namespace: Optional list of strings representing the namespace path.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
`None`
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:8123")
client.store.delete_item(
["documents", "user123"],
key="item456",
)
```
"""
self.http.delete(
"/store/items",
json={"key": key, "namespace": namespace},
headers=headers,
params=params,
)
def search_items(
self,
namespace_prefix: Sequence[str],
/,
filter: Mapping[str, Any] | None = None,
limit: int = 10,
offset: int = 0,
query: str | None = None,
refresh_ttl: bool | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> SearchItemsResponse:
"""Search for items within a namespace prefix.
Args:
namespace_prefix: List of strings representing the namespace prefix.
filter: Optional dictionary of key-value pairs to filter results.
limit: Maximum number of items to return (default is 10).
offset: Number of items to skip before returning results (default is 0).
query: Optional query for natural language search.
refresh_ttl: Whether to refresh the TTL on items returned by this search. If `None`, uses the store's default behavior.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
A list of items matching the search criteria.
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:8123")
items = client.store.search_items(
["documents"],
filter={"author": "John Doe"},
limit=5,
offset=0
)
print(items)
```
```shell
----------------------------------------------------------------
{
"items": [
{
"namespace": ["documents", "user123"],
"key": "item789",
"value": {
"title": "Another Document",
"author": "John Doe"
},
"created_at": "2024-07-30T12:00:00Z",
"updated_at": "2024-07-30T12:00:00Z"
},
# ... additional items ...
]
}
```
"""
payload = {
"namespace_prefix": namespace_prefix,
"filter": filter,
"limit": limit,
"offset": offset,
"query": query,
"refresh_ttl": refresh_ttl,
}
return self.http.post(
"/store/items/search",
json=_provided_vals(payload),
headers=headers,
params=params,
)
def list_namespaces(
self,
prefix: list[str] | None = None,
suffix: list[str] | None = None,
max_depth: int | None = None,
limit: int = 100,
offset: int = 0,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> ListNamespaceResponse:
"""List namespaces with optional match conditions.
Args:
prefix: Optional list of strings representing the prefix to filter namespaces.
suffix: Optional list of strings representing the suffix to filter namespaces.
max_depth: Optional integer specifying the maximum depth of namespaces to return.
limit: Maximum number of namespaces to return (default is 100).
offset: Number of namespaces to skip before returning results (default is 0).
headers: Optional custom headers to include with the request.
Returns:
A list of namespaces matching the criteria.
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:8123")
namespaces = client.store.list_namespaces(
prefix=["documents"],
max_depth=3,
limit=10,
offset=0
)
print(namespaces)
```
```shell
----------------------------------------------------------------
[
["documents", "user123", "reports"],
["documents", "user456", "invoices"],
...
]
```
"""
payload = {
"prefix": prefix,
"suffix": suffix,
"max_depth": max_depth,
"limit": limit,
"offset": offset,
}
return self.http.post(
"/store/namespaces",
json=_provided_vals(payload),
headers=headers,
params=params,
)

View File

@@ -0,0 +1,721 @@
"""Synchronous client for managing threads in LangGraph."""
from __future__ import annotations
from collections.abc import Iterator, Mapping, Sequence
from typing import Any
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk.schema import (
Checkpoint,
Json,
OnConflictBehavior,
PruneStrategy,
QueryParamTypes,
SortOrder,
StreamPart,
Thread,
ThreadSelectField,
ThreadSortBy,
ThreadState,
ThreadStatus,
ThreadStreamMode,
ThreadUpdateStateResponse,
)
class SyncThreadsClient:
"""Synchronous client for managing threads in LangGraph.
This class provides methods to create, retrieve, and manage threads,
which represent conversations or stateful interactions.
???+ example "Example"
```python
client = get_sync_client(url="http://localhost:2024")
thread = client.threads.create(metadata={"user_id": "123"})
```
"""
def __init__(self, http: SyncHttpClient) -> None:
self.http = http
def get(
self,
thread_id: str,
*,
include: Sequence[str] | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Thread:
"""Get a thread by ID.
Args:
thread_id: The ID of the thread to get.
include: Additional fields to include in the response.
Supported values: `"ttl"`.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
`Thread` object.
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:2024")
thread = client.threads.get(
thread_id="my_thread_id"
)
print(thread)
```
```shell
-----------------------------------------------------
{
'thread_id': 'my_thread_id',
'created_at': '2024-07-18T18:35:15.540834+00:00',
'updated_at': '2024-07-18T18:35:15.540834+00:00',
'metadata': {'graph_id': 'agent'}
}
```
"""
query_params: dict[str, Any] = {}
if include:
query_params["include"] = ",".join(include)
if params:
query_params.update(params)
return self.http.get(
f"/threads/{thread_id}",
headers=headers,
params=query_params or None,
)
def create(
self,
*,
metadata: Json = None,
thread_id: str | None = None,
if_exists: OnConflictBehavior | None = None,
supersteps: Sequence[dict[str, Sequence[dict[str, Any]]]] | None = None,
graph_id: str | None = None,
ttl: int | Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Thread:
"""Create a new thread.
Args:
metadata: Metadata to add to thread.
thread_id: ID of thread.
If `None`, ID will be a randomly generated UUID.
if_exists: How to handle duplicate creation. Defaults to 'raise' under the hood.
Must be either 'raise' (raise error if duplicate), or 'do_nothing' (return existing thread).
supersteps: Apply a list of supersteps when creating a thread, each containing a sequence of updates.
Each update has `values` or `command` and `as_node`. Used for copying a thread between deployments.
graph_id: Optional graph ID to associate with the thread.
ttl: Optional time-to-live in minutes for the thread. You can pass an
integer (minutes) or a mapping with keys `ttl` and optional
`strategy` (defaults to "delete").
headers: Optional custom headers to include with the request.
Returns:
The created `Thread`.
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:2024")
thread = client.threads.create(
metadata={"number":1},
thread_id="my-thread-id",
if_exists="raise"
)
```
)
"""
payload: dict[str, Any] = {}
if thread_id:
payload["thread_id"] = thread_id
if metadata or graph_id:
payload["metadata"] = {
**(metadata or {}),
**({"graph_id": graph_id} if graph_id else {}),
}
if if_exists:
payload["if_exists"] = if_exists
if supersteps:
payload["supersteps"] = [
{
"updates": [
{
"values": u["values"],
"command": u.get("command"),
"as_node": u["as_node"],
}
for u in s["updates"]
]
}
for s in supersteps
]
if ttl is not None:
if isinstance(ttl, (int, float)):
payload["ttl"] = {"ttl": ttl, "strategy": "delete"}
else:
payload["ttl"] = ttl
return self.http.post("/threads", json=payload, headers=headers, params=params)
def update(
self,
thread_id: str,
*,
metadata: Mapping[str, Any],
ttl: int | Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Thread:
"""Update a thread.
Args:
thread_id: ID of thread to update.
metadata: Metadata to merge with existing thread metadata.
ttl: Optional time-to-live in minutes for the thread. You can pass an
integer (minutes) or a mapping with keys `ttl` and optional
`strategy` (defaults to "delete").
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
The created `Thread`.
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:2024")
thread = client.threads.update(
thread_id="my-thread-id",
metadata={"number":1},
ttl=43_200,
)
```
"""
payload: dict[str, Any] = {"metadata": metadata}
if ttl is not None:
if isinstance(ttl, (int, float)):
payload["ttl"] = {"ttl": ttl, "strategy": "delete"}
else:
payload["ttl"] = ttl
return self.http.patch(
f"/threads/{thread_id}",
json=payload,
headers=headers,
params=params,
)
def delete(
self,
thread_id: str,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> None:
"""Delete a thread.
Args:
thread_id: The ID of the thread to delete.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
`None`
???+ example "Example Usage"
```python
client.threads.delete(
thread_id="my_thread_id"
)
```
"""
self.http.delete(f"/threads/{thread_id}", headers=headers, params=params)
def search(
self,
*,
metadata: Json = None,
values: Json = None,
ids: Sequence[str] | None = None,
status: ThreadStatus | None = None,
limit: int = 10,
offset: int = 0,
sort_by: ThreadSortBy | None = None,
sort_order: SortOrder | None = None,
select: list[ThreadSelectField] | None = None,
extract: dict[str, str] | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> list[Thread]:
"""Search for threads.
Args:
metadata: Thread metadata to filter on.
values: State values to filter on.
ids: List of thread IDs to filter by.
status: Thread status to filter on.
Must be one of 'idle', 'busy', 'interrupted' or 'error'.
limit: Limit on number of threads to return.
offset: Offset in threads table to start search from.
sort_by: Sort by field.
sort_order: Sort order.
select: List of fields to include in the response.
extract: Dictionary mapping aliases to JSONB paths to extract
from thread data. Paths use dot notation for nested keys and
bracket notation for array indices (e.g.,
`{"last_msg": "values.messages[-1]"}`). Extracted values are
returned in an `extracted` field on each thread. Maximum 10
paths per request.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
List of the threads matching the search parameters.
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:2024")
threads = client.threads.search(
metadata={"number":1},
status="interrupted",
limit=15,
offset=5
)
```
"""
payload: dict[str, Any] = {
"limit": limit,
"offset": offset,
}
if metadata:
payload["metadata"] = metadata
if values:
payload["values"] = values
if ids:
payload["ids"] = ids
if status:
payload["status"] = status
if sort_by:
payload["sort_by"] = sort_by
if sort_order:
payload["sort_order"] = sort_order
if select:
payload["select"] = select
if extract:
payload["extract"] = extract
return self.http.post(
"/threads/search", json=payload, headers=headers, params=params
)
def count(
self,
*,
metadata: Json = None,
values: Json = None,
status: ThreadStatus | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> int:
"""Count threads matching filters.
Args:
metadata: Thread metadata to filter on.
values: State values to filter on.
status: Thread status to filter on.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
int: Number of threads matching the criteria.
"""
payload: dict[str, Any] = {}
if metadata:
payload["metadata"] = metadata
if values:
payload["values"] = values
if status:
payload["status"] = status
return self.http.post(
"/threads/count", json=payload, headers=headers, params=params
)
def copy(
self,
thread_id: str,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> None:
"""Copy a thread.
Args:
thread_id: The ID of the thread to copy.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
`None`
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:2024")
client.threads.copy(
thread_id="my_thread_id"
)
```
"""
return self.http.post(
f"/threads/{thread_id}/copy", json=None, headers=headers, params=params
)
def prune(
self,
thread_ids: Sequence[str],
*,
strategy: PruneStrategy = "delete",
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> dict[str, Any]:
"""Prune threads by ID.
Args:
thread_ids: List of thread IDs to prune.
strategy: The prune strategy. `"delete"` removes threads entirely.
`"keep_latest"` prunes old checkpoints but keeps threads and their
latest state. Defaults to `"delete"`.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
A dict containing `pruned_count` (number of threads pruned).
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:2024")
result = client.threads.prune(
thread_ids=["thread_1", "thread_2"],
)
print(result) # {'pruned_count': 2}
```
"""
payload: dict[str, Any] = {
"thread_ids": thread_ids,
}
if strategy != "delete":
payload["strategy"] = strategy
return self.http.post(
"/threads/prune", json=payload, headers=headers, params=params
)
def get_state(
self,
thread_id: str,
checkpoint: Checkpoint | None = None,
checkpoint_id: str | None = None, # deprecated
*,
subgraphs: bool = False,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> ThreadState:
"""Get the state of a thread.
Args:
thread_id: The ID of the thread to get the state of.
checkpoint: The checkpoint to get the state of.
subgraphs: Include subgraphs states.
headers: Optional custom headers to include with the request.
Returns:
The thread of the state.
???+ example "Example Usage"
```python
client = get_sync_client(url="http://localhost:2024")
thread_state = client.threads.get_state(
thread_id="my_thread_id",
checkpoint_id="my_checkpoint_id"
)
print(thread_state)
```
```shell
----------------------------------------------------------------------------------------------------------------------------------------------------------------------
{
'values': {
'messages': [
{
'content': 'how are you?',
'additional_kwargs': {},
'response_metadata': {},
'type': 'human',
'name': None,
'id': 'fe0a5778-cfe9-42ee-b807-0adaa1873c10',
'example': False
},
{
'content': "I'm doing well, thanks for asking! I'm an AI assistant created by Anthropic to be helpful, honest, and harmless.",
'additional_kwargs': {},
'response_metadata': {},
'type': 'ai',
'name': None,
'id': 'run-159b782c-b679-4830-83c6-cef87798fe8b',
'example': False,
'tool_calls': [],
'invalid_tool_calls': [],
'usage_metadata': None
}
]
},
'next': [],
'checkpoint':
{
'thread_id': 'e2496803-ecd5-4e0c-a779-3226296181c2',
'checkpoint_ns': '',
'checkpoint_id': '1ef4a9b8-e6fb-67b1-8001-abd5184439d1'
}
'metadata':
{
'step': 1,
'run_id': '1ef4a9b8-d7da-679a-a45a-872054341df2',
'source': 'loop',
'writes':
{
'agent':
{
'messages': [
{
'id': 'run-159b782c-b679-4830-83c6-cef87798fe8b',
'name': None,
'type': 'ai',
'content': "I'm doing well, thanks for asking! I'm an AI assistant created by Anthropic to be helpful, honest, and harmless.",
'example': False,
'tool_calls': [],
'usage_metadata': None,
'additional_kwargs': {},
'response_metadata': {},
'invalid_tool_calls': []
}
]
}
},
'user_id': None,
'graph_id': 'agent',
'thread_id': 'e2496803-ecd5-4e0c-a779-3226296181c2',
'created_by': 'system',
'assistant_id': 'fe096781-5601-53d2-b2f6-0d3403f7e9ca'},
'created_at': '2024-07-25T15:35:44.184703+00:00',
'parent_config':
{
'thread_id': 'e2496803-ecd5-4e0c-a779-3226296181c2',
'checkpoint_ns': '',
'checkpoint_id': '1ef4a9b8-d80d-6fa7-8000-9300467fad0f'
}
}
```
"""
if checkpoint:
return self.http.post(
f"/threads/{thread_id}/state/checkpoint",
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
headers=headers,
params=params,
)
elif checkpoint_id:
get_params = {"subgraphs": subgraphs}
if params:
get_params = {**get_params, **dict(params)}
return self.http.get(
f"/threads/{thread_id}/state/{checkpoint_id}",
params=get_params,
headers=headers,
)
else:
get_params = {"subgraphs": subgraphs}
if params:
get_params = {**get_params, **dict(params)}
return self.http.get(
f"/threads/{thread_id}/state",
params=get_params,
headers=headers,
)
def update_state(
self,
thread_id: str,
values: dict[str, Any] | Sequence[dict] | None,
*,
as_node: str | None = None,
checkpoint: Checkpoint | None = None,
checkpoint_id: str | None = None, # deprecated
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> ThreadUpdateStateResponse:
"""Update the state of a thread.
Args:
thread_id: The ID of the thread to update.
values: The values to update the state with.
as_node: Update the state as if this node had just executed.
checkpoint: The checkpoint to update the state of.
headers: Optional custom headers to include with the request.
Returns:
Response after updating a thread's state.
???+ example "Example Usage"
```python
response = await client.threads.update_state(
thread_id="my_thread_id",
values={"messages":[{"role": "user", "content": "hello!"}]},
as_node="my_node",
)
print(response)
----------------------------------------------------------------------------------------------------------------------------------------------------------------------
{
'checkpoint': {
'thread_id': 'e2496803-ecd5-4e0c-a779-3226296181c2',
'checkpoint_ns': '',
'checkpoint_id': '1ef4a9b8-e6fb-67b1-8001-abd5184439d1',
'checkpoint_map': {}
}
}
```
"""
payload: dict[str, Any] = {
"values": values,
}
if checkpoint_id:
payload["checkpoint_id"] = checkpoint_id
if checkpoint:
payload["checkpoint"] = checkpoint
if as_node:
payload["as_node"] = as_node
return self.http.post(
f"/threads/{thread_id}/state", json=payload, headers=headers, params=params
)
def get_history(
self,
thread_id: str,
*,
limit: int = 10,
before: str | Checkpoint | None = None,
metadata: Mapping[str, Any] | None = None,
checkpoint: Checkpoint | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> list[ThreadState]:
"""Get the state history of a thread.
Args:
thread_id: The ID of the thread to get the state history for.
checkpoint: Return states for this subgraph. If empty defaults to root.
limit: The maximum number of states to return.
before: Return states before this checkpoint.
metadata: Filter states by metadata key-value pairs.
headers: Optional custom headers to include with the request.
Returns:
The state history of the `Thread`.
???+ example "Example Usage"
```python
thread_state = client.threads.get_history(
thread_id="my_thread_id",
limit=5,
before="my_timestamp",
metadata={"name":"my_name"}
)
```
"""
payload: dict[str, Any] = {
"limit": limit,
}
if before:
payload["before"] = before
if metadata:
payload["metadata"] = metadata
if checkpoint:
payload["checkpoint"] = checkpoint
return self.http.post(
f"/threads/{thread_id}/history",
json=payload,
headers=headers,
params=params,
)
def join_stream(
self,
thread_id: str,
*,
stream_mode: ThreadStreamMode | Sequence[ThreadStreamMode] = "run_modes",
last_event_id: str | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Iterator[StreamPart]:
"""Get a stream of events for a thread.
Args:
thread_id: The ID of the thread to get the stream for.
last_event_id: The ID of the last event to get.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
Returns:
An iterator of stream parts.
???+ example "Example Usage"
```python
for chunk in client.threads.join_stream(
thread_id="my_thread_id",
last_event_id="my_event_id",
stream_mode="run_modes",
):
print(chunk)
```
"""
query_params = {
"stream_mode": stream_mode,
}
if params:
query_params.update(params)
return self.http.stream(
f"/threads/{thread_id}/stream",
"GET",
headers={
**({"Last-Event-ID": last_event_id} if last_event_id else {}),
**(headers or {}),
},
params=query_params,
)

View File

@@ -0,0 +1,847 @@
from __future__ import annotations
import inspect
import typing
from collections.abc import Callable, Sequence
from langgraph_sdk.auth import exceptions, types
TH = typing.TypeVar("TH", bound=types.Handler)
AH = typing.TypeVar("AH", bound=types.Authenticator)
class Auth:
"""Add custom authentication and authorization management to your LangGraph application.
The Auth class provides a unified system for handling authentication and
authorization in LangGraph applications. It supports custom user authentication
protocols and fine-grained authorization rules for different resources and
actions.
To use, create a separate python file and add the path to the file to your
LangGraph API configuration file (`langgraph.json`). Within that file, create
an instance of the Auth class and register authentication and authorization
handlers as needed.
Example `langgraph.json` file:
```json
{
"dependencies": ["."],
"graphs": {
"agent": "./my_agent/agent.py:graph"
},
"env": ".env",
"auth": {
"path": "./auth.py:my_auth"
}
```
Then the LangGraph server will load your auth file and run it server-side whenever a request comes in.
???+ example "Basic Usage"
```python
from langgraph_sdk import Auth
my_auth = Auth()
async def verify_token(token: str) -> str:
# Verify token and return user_id
# This would typically be a call to your auth server
return "user_id"
@auth.authenticate
async def authenticate(authorization: str) -> str:
# Verify token and return user_id
result = await verify_token(authorization)
if result != "user_id":
raise Auth.exceptions.HTTPException(
status_code=401, detail="Unauthorized"
)
return result
# Global fallback handler
@auth.on
async def authorize_default(params: Auth.on.value):
return False # Reject all requests (default behavior)
@auth.on.threads.create
async def authorize_thread_create(params: Auth.on.threads.create.value):
# Allow the allowed user to create a thread
assert params.get("metadata", {}).get("owner") == "allowed_user"
@auth.on.store
async def authorize_store(ctx: Auth.types.AuthContext, value: Auth.types.on.store.value):
# Automatically scope all store operations to the user's namespace.
namespace = tuple(value["namespace"]) if value.get("namespace") else ()
assert isinstance(namespace, tuple)
if not namespace or namespace[0] != ctx.user.identity:
namespace = (ctx.user.identity, *namespace)
value["namespace"] = namespace
```
???+ note "Request Processing Flow"
1. Authentication (your `@auth.authenticate` handler) is performed first on **every request**
2. For authorization, the most specific matching handler is called:
* If a handler exists for the exact resource and action, it is used (e.g., `@auth.on.threads.create`)
* Otherwise, if a handler exists for the resource with any action, it is used (e.g., `@auth.on.threads`)
* Finally, if no specific handlers match, the global handler is used (e.g., `@auth.on`)
* If no global handler is set, the request is accepted
This allows you to set default behavior with a global handler while
overriding specific routes as needed.
"""
__slots__ = (
"_authenticate_handler",
"_global_handlers",
"_handler_cache",
"_handlers",
"on",
)
types = types
"""Reference to auth type definitions.
Provides access to all type definitions used in the auth system,
like ThreadsCreate, AssistantsRead, etc."""
exceptions = exceptions
"""Reference to auth exception definitions.
Provides access to all exception definitions used in the auth system,
like HTTPException, etc.
"""
def __init__(self) -> None:
self.on = _On(self)
"""Entry point for authorization handlers that control access to specific resources.
The on class provides a flexible way to define authorization rules for different
resources and actions in your application. It supports three main usage patterns:
1. Global handlers that run for all resources and actions
2. Resource-specific handlers that run for all actions on a resource
3. Resource and action specific handlers for fine-grained control
Each handler must be an async function that accepts two parameters:
- ctx (AuthContext): Contains request context and authenticated user info
- value: The data being authorized (type varies by endpoint)
The handler should return one of:
- None or True: Accept the request
- False: Reject with 403 error
- FilterType: Apply filtering rules to the response
???+ example "Examples"
Global handler for all requests:
```python
@auth.on
async def reject_unhandled_requests(ctx: AuthContext, value: Any) -> None:
print(f"Request to {ctx.path} by {ctx.user.identity}")
return False
```
Resource-specific handler. This would take precedence over the global handler
for all actions on the `threads` resource:
```python
@auth.on.threads
async def check_thread_access(ctx: AuthContext, value: Any) -> bool:
# Allow access only to threads created by the user
return value.get("created_by") == ctx.user.identity
```
Resource and action specific handler:
```python
@auth.on.threads.delete
async def prevent_thread_deletion(ctx: AuthContext, value: Any) -> bool:
# Only admins can delete threads
return "admin" in ctx.user.permissions
```
Multiple resources or actions:
```python
@auth.on(resources=["threads", "runs"], actions=["create", "update"])
async def rate_limit_writes(ctx: AuthContext, value: Any) -> bool:
# Implement rate limiting for write operations
return await check_rate_limit(ctx.user.identity)
```
Auth for the `store` resource is a bit different since its structure is developer defined.
You typically want to scope store operations by rewriting the namespace to include the user's identity.
The `value` dict is mutable — changes to `value["namespace"]` are used by the server for the actual operation.
```python
@auth.on.store
async def authorize_store(ctx: AuthContext, value: Auth.types.on.store.value):
# Automatically scope all store operations to the user's namespace.
namespace = tuple(value["namespace"]) if value.get("namespace") else ()
assert isinstance(namespace, tuple)
if not namespace or namespace[0] != ctx.user.identity:
namespace = (ctx.user.identity, *namespace)
value["namespace"] = namespace
```
You can also register handlers for specific store actions:
```python
@auth.on.store.put
async def on_put(ctx: AuthContext, value: Auth.types.on.store.put.value):
# value has typed fields: namespace, key, value, index
...
@auth.on.store.get
async def on_get(ctx: AuthContext, value: Auth.types.on.store.get.value):
# value has typed fields: namespace, key
...
```
"""
# These are accessed by the API. Changes to their names or types is
# will be considered a breaking change.
self._handlers: dict[tuple[str, str], list[types.Handler]] = {}
self._global_handlers: list[types.Handler] = []
self._authenticate_handler: types.Authenticator | None = None
self._handler_cache: dict[tuple[str, str], types.Handler] = {}
def authenticate(self, fn: AH) -> AH:
"""Register an authentication handler function.
The authentication handler is responsible for verifying credentials
and returning user scopes. It can accept any of the following parameters
by name:
- request (Request): The raw ASGI request object
- path (str): The request path, e.g., "/threads/abcd-1234-abcd-1234/runs/abcd-1234-abcd-1234/stream"
- method (str): The HTTP method, e.g., "GET"
- path_params (dict[str, str]): URL path parameters, e.g., {"thread_id": "abcd-1234-abcd-1234", "run_id": "abcd-1234-abcd-1234"}
- query_params (dict[str, str]): URL query parameters, e.g., {"stream": "true"}
- headers (dict[bytes, bytes]): Request headers
- authorization (str | None): The Authorization header value (e.g., "Bearer <token>")
Args:
fn: The authentication handler function to register.
Must return a representation of the user. This could be a:
- string (the user id)
- dict containing {"identity": str, "permissions": list[str]}
- or an object with identity and permissions properties
Permissions can be optionally used by your handlers downstream.
Returns:
The registered handler function.
Raises:
ValueError: If an authentication handler is already registered.
???+ example "Examples"
Basic token authentication:
```python
@auth.authenticate
async def authenticate(authorization: str) -> str:
user_id = verify_token(authorization)
return user_id
```
Accept the full request context:
```python
@auth.authenticate
async def authenticate(
method: str,
path: str,
headers: dict[str, bytes]
) -> str:
user = await verify_request(method, path, headers)
return user
```
Return user name and permissions:
```python
@auth.authenticate
async def authenticate(
method: str,
path: str,
headers: dict[str, bytes]
) -> Auth.types.MinimalUserDict:
permissions, user = await verify_request(method, path, headers)
# Permissions could be things like ["runs:read", "runs:write", "threads:read", "threads:write"]
return {
"identity": user["id"],
"permissions": permissions,
"display_name": user["name"],
}
```
"""
if self._authenticate_handler is not None:
raise ValueError(
f"Authentication handler already set as {self._authenticate_handler}."
)
self._authenticate_handler = fn
return fn
## Helper types & utilities
V = typing.TypeVar("V", contravariant=True)
class _ActionHandler(typing.Protocol[V]):
async def __call__(
self, *, ctx: types.AuthContext, value: V
) -> types.HandlerResult: ...
T = typing.TypeVar("T", covariant=True)
class _ResourceActionOn(typing.Generic[T]):
def __init__(
self,
auth: Auth,
resource: typing.Literal["threads", "crons", "assistants"],
action: typing.Literal[
"create", "read", "update", "delete", "search", "create_run"
],
value: type[T],
) -> None:
self.auth = auth
self.resource = resource
self.action = action
self.value = value
def __call__(self, fn: _ActionHandler[T]) -> _ActionHandler[T]:
_validate_handler(fn)
_register_handler(self.auth, self.resource, self.action, fn)
return fn
VCreate = typing.TypeVar("VCreate", covariant=True)
VUpdate = typing.TypeVar("VUpdate", covariant=True)
VRead = typing.TypeVar("VRead", covariant=True)
VDelete = typing.TypeVar("VDelete", covariant=True)
VSearch = typing.TypeVar("VSearch", covariant=True)
class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
"""
Generic base class for resource-specific handlers.
"""
value: type[VCreate | VUpdate | VRead | VDelete | VSearch]
Create: type[VCreate]
Read: type[VRead]
Update: type[VUpdate]
Delete: type[VDelete]
Search: type[VSearch]
def __init__(
self,
auth: Auth,
resource: typing.Literal["threads", "crons", "assistants"],
) -> None:
self.auth = auth
self.resource = resource
self.create: _ResourceActionOn[VCreate] = _ResourceActionOn(
auth, resource, "create", self.Create
)
self.read: _ResourceActionOn[VRead] = _ResourceActionOn(
auth, resource, "read", self.Read
)
self.update: _ResourceActionOn[VUpdate] = _ResourceActionOn(
auth, resource, "update", self.Update
)
self.delete: _ResourceActionOn[VDelete] = _ResourceActionOn(
auth, resource, "delete", self.Delete
)
self.search: _ResourceActionOn[VSearch] = _ResourceActionOn(
auth, resource, "search", self.Search
)
@typing.overload
def __call__(
self,
fn: (
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]
| _ActionHandler[dict[str, typing.Any]]
),
) -> _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]: ...
@typing.overload
def __call__(
self,
*,
resources: str | Sequence[str],
actions: str | Sequence[str] | None = None,
) -> Callable[
[_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]],
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
]: ...
def __call__(
self,
fn: (
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]
| _ActionHandler[dict[str, typing.Any]]
| None
) = None,
*,
resources: str | Sequence[str] | None = None,
actions: str | Sequence[str] | None = None,
) -> (
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]
| Callable[
[_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]],
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
]
):
if fn is not None:
_validate_handler(fn)
return typing.cast(
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
_register_handler(self.auth, self.resource, "*", fn),
)
def decorator(
handler: _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
) -> _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]:
_validate_handler(handler)
return typing.cast(
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
_register_handler(self.auth, self.resource, "*", handler),
)
# Accept keyword-only parameters for future filtering behavior; referenced to satisfy linters.
_ = resources, actions
return decorator
class _AssistantsOn(
_ResourceOn[
types.AssistantsCreate,
types.AssistantsRead,
types.AssistantsUpdate,
types.AssistantsDelete,
types.AssistantsSearch,
]
):
value = (
types.AssistantsCreate
| types.AssistantsRead
| types.AssistantsUpdate
| types.AssistantsDelete
| types.AssistantsSearch
)
Create = types.AssistantsCreate
Read = types.AssistantsRead
Update = types.AssistantsUpdate
Delete = types.AssistantsDelete
Search = types.AssistantsSearch
class _ThreadsOn(
_ResourceOn[
types.ThreadsCreate,
types.ThreadsRead,
types.ThreadsUpdate,
types.ThreadsDelete,
types.ThreadsSearch,
]
):
value = (
types.ThreadsCreate
| types.ThreadsRead
| types.ThreadsUpdate
| types.ThreadsDelete
| types.ThreadsSearch
| types.RunsCreate
)
Create = types.ThreadsCreate
Read = types.ThreadsRead
Update = types.ThreadsUpdate
Delete = types.ThreadsDelete
Search = types.ThreadsSearch
CreateRun = types.RunsCreate
def __init__(
self,
auth: Auth,
resource: typing.Literal["threads", "crons", "assistants"],
) -> None:
super().__init__(auth, resource)
self.create_run: _ResourceActionOn[types.RunsCreate] = _ResourceActionOn(
auth, resource, "create_run", self.CreateRun
)
class _CronsOn(
_ResourceOn[
types.CronsCreate,
types.CronsRead,
types.CronsUpdate,
types.CronsDelete,
types.CronsSearch,
]
):
value = type[
types.CronsCreate
| types.CronsRead
| types.CronsUpdate
| types.CronsDelete
| types.CronsSearch
]
Create = types.CronsCreate
Read = types.CronsRead
Update = types.CronsUpdate
Delete = types.CronsDelete
Search = types.CronsSearch
class _StoreActionOn(typing.Generic[T]):
"""Decorator for registering a handler for a specific store action."""
def __init__(
self,
auth: Auth,
action: typing.Literal["put", "get", "search", "delete", "list_namespaces"],
value: type[T],
) -> None:
self.auth = auth
self.action = action
self.value = value
def __call__(self, fn: _ActionHandler[T]) -> _ActionHandler[T]:
_validate_handler(fn)
_register_handler(self.auth, "store", self.action, fn)
return fn
class _StoreOn:
def __init__(self, auth: Auth) -> None:
self._auth = auth
self.put = _StoreActionOn(auth, "put", types.StorePut)
"""Register a handler for store put operations.
???+ example "Example"
```python
@auth.on.store.put
async def on_store_put(ctx: Auth.types.AuthContext, value: Auth.types.on.store.put.value):
# Scope puts to user's namespace
...
```
"""
self.get = _StoreActionOn(auth, "get", types.StoreGet)
"""Register a handler for store get operations.
???+ example "Example"
```python
@auth.on.store.get
async def on_store_get(ctx: Auth.types.AuthContext, value: Auth.types.on.store.get.value):
# Scope gets to user's namespace
...
```
"""
self.search = _StoreActionOn(auth, "search", types.StoreSearch)
"""Register a handler for store search operations.
???+ example "Example"
```python
@auth.on.store.search
async def on_store_search(ctx: Auth.types.AuthContext, value: Auth.types.on.store.search.value):
# Scope searches to user's namespace
...
```
"""
self.delete = _StoreActionOn(auth, "delete", types.StoreDelete)
"""Register a handler for store delete operations.
???+ example "Example"
```python
@auth.on.store.delete
async def on_store_delete(ctx: Auth.types.AuthContext, value: Auth.types.on.store.delete.value):
# Scope deletes to user's namespace
...
```
"""
self.list_namespaces = _StoreActionOn(
auth, "list_namespaces", types.StoreListNamespaces
)
"""Register a handler for store list_namespaces operations.
???+ example "Example"
```python
@auth.on.store.list_namespaces
async def on_list_ns(ctx: Auth.types.AuthContext, value: Auth.types.on.store.list_namespaces.value):
# Scope namespace listing to user's prefix
...
```
"""
@typing.overload
def __call__(
self,
*,
actions: (
typing.Literal["put", "get", "search", "list_namespaces", "delete"]
| Sequence[
typing.Literal["put", "get", "search", "list_namespaces", "delete"]
]
| None
) = None,
) -> Callable[[AHO], AHO]: ...
@typing.overload
def __call__(self, fn: AHO) -> AHO: ...
def __call__(
self,
fn: AHO | None = None,
*,
actions: (
typing.Literal["put", "get", "search", "list_namespaces", "delete"]
| Sequence[
typing.Literal["put", "get", "search", "list_namespaces", "delete"]
]
| None
) = None,
) -> AHO | Callable[[AHO], AHO]:
"""Register a handler for specific resources and actions.
Can be used as a decorator or with explicit resource/action parameters:
@auth.on.store
async def handler(): ... # Handle all store ops
@auth.on.store(actions=("put", "get", "search", "delete"))
async def handler(): ... # Handle specific store ops
@auth.on.store.put
async def handler(): ... # Handle store.put ops
"""
if fn is not None:
# Used as a plain decorator
_register_handler(self._auth, "store", None, fn)
return fn
# Used with parameters, return a decorator
def decorator(
handler: AHO,
) -> AHO:
if isinstance(actions, str):
action_list = [actions]
else:
action_list = list(actions) if actions is not None else ["*"]
for action in action_list:
_register_handler(self._auth, "store", action, handler)
return handler
return decorator
AHO = typing.TypeVar("AHO", bound=_ActionHandler[dict[str, typing.Any]])
class _On:
"""Entry point for authorization handlers that control access to specific resources.
The _On class provides a flexible way to define authorization rules for different resources
and actions in your application. It supports three main usage patterns:
1. Global handlers that run for all resources and actions
2. Resource-specific handlers that run for all actions on a resource
3. Resource and action specific handlers for fine-grained control
Each handler must be an async function that accepts two parameters:
- ctx (AuthContext): Contains request context and authenticated user info
- value: The data being authorized (type varies by endpoint)
The handler should return one of:
- None or True: Accept the request
- False: Reject with 403 error
- FilterType: Apply filtering rules to the response
???+ example "Examples"
Global handler for all requests:
```python
@auth.on
async def log_all_requests(ctx: AuthContext, value: Any) -> None:
print(f"Request to {ctx.path} by {ctx.user.identity}")
return True
```
Resource-specific handler:
```python
@auth.on.threads
async def check_thread_access(ctx: AuthContext, value: Any) -> bool:
# Allow access only to threads created by the user
return value.get("created_by") == ctx.user.identity
```
Resource and action specific handler:
```python
@auth.on.threads.delete
async def prevent_thread_deletion(ctx: AuthContext, value: Any) -> bool:
# Only admins can delete threads
return "admin" in ctx.user.permissions
```
Multiple resources or actions:
```python
@auth.on(resources=["threads", "runs"], actions=["create", "update"])
async def rate_limit_writes(ctx: AuthContext, value: Any) -> bool:
# Implement rate limiting for write operations
return await check_rate_limit(ctx.user.identity)
```
"""
__slots__ = (
"_auth",
"assistants",
"crons",
"runs",
"store",
"threads",
"value",
)
def __init__(self, auth: Auth) -> None:
self._auth = auth
self.assistants = _AssistantsOn(auth, "assistants")
self.threads = _ThreadsOn(auth, "threads")
self.crons = _CronsOn(auth, "crons")
self.store = _StoreOn(auth)
self.value = dict[str, typing.Any]
@typing.overload
def __call__(
self,
*,
resources: str | Sequence[str],
actions: str | Sequence[str] | None = None,
) -> Callable[[AHO], AHO]: ...
@typing.overload
def __call__(self, fn: AHO) -> AHO: ...
def __call__(
self,
fn: AHO | None = None,
*,
resources: str | Sequence[str] | None = None,
actions: str | Sequence[str] | None = None,
) -> AHO | Callable[[AHO], AHO]:
"""Register a handler for specific resources and actions.
Can be used as a decorator or with explicit resource/action parameters:
@auth.on
async def handler(): ... # Global handler
@auth.on(resources="threads")
async def handler(): ... # types.Handler for all thread actions
@auth.on(resources="threads", actions="create")
async def handler(): ... # types.Handler for thread creation
"""
if fn is not None:
# Used as a plain decorator
_register_handler(self._auth, None, None, fn)
return fn
# Used with parameters, return a decorator
def decorator(
handler: AHO,
) -> AHO:
if isinstance(resources, str):
resource_list = [resources]
else:
resource_list = list(resources) if resources is not None else ["*"]
if isinstance(actions, str):
action_list = [actions]
else:
action_list = list(actions) if actions is not None else ["*"]
for resource in resource_list:
for action in action_list:
_register_handler(self._auth, resource, action, handler)
return handler
return decorator
def _register_handler(
auth: Auth,
resource: str | None,
action: str | None,
fn: types.Handler,
) -> types.Handler:
_validate_handler(fn)
resource = resource or "*"
action = action or "*"
if resource == "*" and action == "*":
if auth._global_handlers:
raise ValueError("Global handler already set.")
auth._global_handlers.append(fn)
else:
r = resource if resource is not None else "*"
a = action if action is not None else "*"
if (r, a) in auth._handlers:
raise ValueError(f"types.Handler already set for {r}, {a}.")
auth._handlers[(r, a)] = [fn]
return fn
def _validate_handler(fn: Callable[..., typing.Any]) -> None:
"""Validates that an auth handler function meets the required signature.
Auth handlers must:
1. Be async functions
2. Accept a ctx parameter of type AuthContext
3. Accept a value parameter for the data being authorized
"""
if not inspect.iscoroutinefunction(fn):
raise ValueError(
f"Auth handler '{getattr(fn, '__name__', fn)}' must be an async function. "
"Add 'async' before 'def' to make it asynchronous and ensure"
" any IO operations are non-blocking."
)
sig = inspect.signature(fn)
if "ctx" not in sig.parameters:
raise ValueError(
f"Auth handler '{getattr(fn, '__name__', fn)}' must have a 'ctx: AuthContext' parameter. "
"Update the function signature to include this required parameter."
)
if "value" not in sig.parameters:
raise ValueError(
f"Auth handler '{getattr(fn, '__name__', fn)}' must have a 'value' parameter. "
" The value contains the mutable data being sent to the endpoint."
"Update the function signature to include this required parameter."
)
def is_studio_user(
user: types.MinimalUser | types.BaseUser | types.MinimalUserDict,
) -> bool:
return (
isinstance(user, types.StudioUser)
or (isinstance(user, dict) and user.get("kind") == "StudioUser") # ty: ignore[invalid-argument-type]
)
__all__ = ["Auth", "exceptions", "types"]

View File

@@ -0,0 +1,59 @@
"""Exceptions used in the auth system."""
from __future__ import annotations
import http
from collections.abc import Mapping
class HTTPException(Exception):
"""HTTP exception that you can raise to return a specific HTTP error response.
Since this is defined in the auth module, we default to a 401 status code.
Args:
status_code: HTTP status code for the error. Defaults to 401 "Unauthorized".
detail: Detailed error message. If `None`, uses a default
message based on the status code.
headers: Additional HTTP headers to include in the error response.
Example:
Default:
```python
raise HTTPException()
# HTTPException(status_code=401, detail='Unauthorized')
```
Add headers:
```python
raise HTTPException(headers={"X-Custom-Header": "Custom Value"})
# HTTPException(status_code=401, detail='Unauthorized', headers={"WWW-Authenticate": "Bearer"})
```
Custom error:
```python
raise HTTPException(status_code=404, detail="Not found")
```
"""
def __init__(
self,
status_code: int = 401,
detail: str | None = None,
headers: Mapping[str, str] | None = None,
) -> None:
if detail is None:
detail = http.HTTPStatus(status_code).phrase
self.status_code = status_code
self.detail = detail
self.headers = headers
def __str__(self) -> str:
return f"{self.status_code}: {self.detail}"
def __repr__(self) -> str:
class_name = self.__class__.__name__
return f"{class_name}(status_code={self.status_code!r}, detail={self.detail!r})"
__all__ = ["HTTPException"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
"""The LangGraph client implementations connect to the LangGraph API.
This module provides both asynchronous (`get_client(url="http://localhost:2024")` or
`LangGraphClient`) and synchronous (`get_sync_client(url="http://localhost:2024")` or
`SyncLanggraphClient`) clients to interacting with the LangGraph API's core resources
such as Assistants, Threads, Runs, and Cron jobs, as well as its persistent document
Store.
"""
from __future__ import annotations
from langgraph_sdk._async.assistants import AssistantsClient
# Re-export factory functions
# Re-export async clients
from langgraph_sdk._async.client import LangGraphClient, get_client
from langgraph_sdk._async.cron import CronClient
from langgraph_sdk._async.http import HttpClient, _adecode_json, _aencode_json
from langgraph_sdk._async.runs import RunsClient
from langgraph_sdk._async.store import StoreClient
from langgraph_sdk._async.threads import ThreadsClient
from langgraph_sdk._shared.utilities import configure_loopback_transports
from langgraph_sdk._sync.assistants import SyncAssistantsClient
# Re-export sync clients
from langgraph_sdk._sync.client import SyncLangGraphClient, get_sync_client
from langgraph_sdk._sync.cron import SyncCronClient
from langgraph_sdk._sync.http import SyncHttpClient, _decode_json, _encode_json
from langgraph_sdk._sync.runs import SyncRunsClient
from langgraph_sdk._sync.store import SyncStoreClient
from langgraph_sdk._sync.threads import SyncThreadsClient
__all__ = [
"AssistantsClient",
"CronClient",
"HttpClient",
"LangGraphClient",
"RunsClient",
"StoreClient",
"SyncAssistantsClient",
"SyncCronClient",
"SyncHttpClient",
"SyncLangGraphClient",
"SyncRunsClient",
"SyncStoreClient",
"SyncThreadsClient",
"ThreadsClient",
"_adecode_json",
"_aencode_json",
"_decode_json",
"_encode_json",
"configure_loopback_transports",
"get_client",
"get_sync_client",
]

View File

@@ -0,0 +1,466 @@
"""Custom encryption support for LangGraph.
.. warning::
This API is in beta and may change in future versions.
This module provides a framework for implementing custom at-rest encryption
in LangGraph applications. Similar to the Auth system, it allows developers
to define custom encryption and decryption handlers that are executed
server-side.
"""
from __future__ import annotations
import functools
import inspect
import typing
import warnings
from langgraph_sdk.encryption import types
class LangGraphBetaWarning(UserWarning):
"""Warning for beta features in LangGraph SDK."""
@functools.lru_cache(maxsize=1)
def _warn_encryption_beta() -> None:
warnings.warn(
"The Encryption API is in beta and may change in future versions.",
LangGraphBetaWarning,
stacklevel=4,
)
class DuplicateHandlerError(Exception):
"""Raised when attempting to register a duplicate encryption/decryption handler."""
pass
def _validate_handler(fn: typing.Callable, handler_type: str) -> None:
"""Validate that a handler function has the correct signature.
Args:
fn: The handler function to validate
handler_type: Description of the handler for error messages
Raises:
TypeError: If the handler is not an async function or has wrong parameter count
"""
if not inspect.iscoroutinefunction(fn):
raise TypeError(f"{handler_type} must be an async function, got {type(fn)}")
sig = inspect.signature(fn)
params = [
p
for p in sig.parameters.values()
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
]
if len(params) != 2:
raise TypeError(
f"{handler_type} must accept exactly 2 parameters "
f"(ctx, data), got {len(params)}"
)
class _EncryptDecorators:
"""Decorators for encryption handlers.
Provides @encryption.encrypt.blob and @encryption.encrypt.json decorators for
registering encryption functions.
"""
def __init__(self, parent: Encryption):
self._parent = parent
def blob(self, fn: types.BlobEncryptor) -> types.BlobEncryptor:
"""Register a blob encryption handler.
The handler will be called to encrypt opaque data like checkpoint blobs.
Example:
```python
@encryption.encrypt.blob
async def encrypt_blob(ctx: EncryptionContext, blob: bytes) -> bytes:
# Encrypt the blob using your encryption service
return encrypted_blob
```
Args:
fn: The encryption handler function
Returns:
The registered handler function
Raises:
DuplicateHandlerError: If blob encryptor already registered
TypeError: If handler has invalid signature
"""
if self._parent._blob_encryptor is not None:
raise DuplicateHandlerError("Blob encryptor already registered")
_validate_handler(fn, "Blob encryptor")
self._parent._blob_encryptor = fn
return fn
def json(self, fn: types.JsonEncryptor) -> types.JsonEncryptor:
"""Register the JSON encryption handler.
Example:
```python
@encryption.encrypt.json
async def encrypt_json(ctx: EncryptionContext, data: dict) -> dict:
# Encrypt the data
return encrypt_data(data)
```
Args:
fn: The encryption handler function
Returns:
The registered handler function
Raises:
DuplicateHandlerError: If JSON encryptor already registered
TypeError: If handler has invalid signature
"""
if self._parent._json_encryptor is not None:
raise DuplicateHandlerError("JSON encryptor already registered")
_validate_handler(fn, "JSON encryptor")
self._parent._json_encryptor = fn
return fn
class _DecryptDecorators:
"""Decorators for decryption handlers.
Provides @encryption.decrypt.blob and @encryption.decrypt.json decorators for
registering decryption functions.
"""
def __init__(self, parent: Encryption):
self._parent = parent
def blob(self, fn: types.BlobDecryptor) -> types.BlobDecryptor:
"""Register a blob decryption handler.
The handler will be called to decrypt opaque data like checkpoint blobs.
Example:
```python
@encryption.decrypt.blob
async def decrypt_blob(ctx: EncryptionContext, blob: bytes) -> bytes:
# Decrypt the blob using your encryption service
return decrypted_blob
```
Args:
fn: The decryption handler function
Returns:
The registered handler function
Raises:
DuplicateHandlerError: If blob decryptor already registered
TypeError: If handler has invalid signature
"""
if self._parent._blob_decryptor is not None:
raise DuplicateHandlerError("Blob decryptor already registered")
_validate_handler(fn, "Blob decryptor")
self._parent._blob_decryptor = fn
return fn
def json(self, fn: types.JsonDecryptor) -> types.JsonDecryptor:
"""Register the JSON decryption handler.
Example:
```python
@encryption.decrypt.json
async def decrypt_json(ctx: EncryptionContext, data: dict) -> dict:
# Decrypt the data
return decrypt_data(data)
```
Args:
fn: The decryption handler function
Returns:
The registered handler function
Raises:
DuplicateHandlerError: If JSON decryptor already registered
TypeError: If handler has invalid signature
"""
if self._parent._json_decryptor is not None:
raise DuplicateHandlerError("JSON decryptor already registered")
_validate_handler(fn, "JSON decryptor")
self._parent._json_decryptor = fn
return fn
class Encryption:
"""Add custom at-rest encryption to your LangGraph application.
.. warning::
This API is in beta and may change in future versions.
The Encryption class provides a system for implementing custom encryption
of data at rest in LangGraph applications. It supports encryption of
both opaque blobs (like checkpoints) and structured JSON data (like
metadata, context, kwargs, values, etc.).
To use, create a separate Python file and add the path to the file to your
LangGraph API configuration file (`langgraph.json`). Within that file, create
an instance of the Encryption class and register encryption and decryption
handlers as needed.
Example `langgraph.json` file:
```json
{
"dependencies": ["."],
"graphs": {
"agent": "./my_agent/agent.py:graph"
},
"env": ".env",
"encryption": {
"path": "./encryption.py:my_encryption"
}
}
```
Then the LangGraph server will load your encryption file and use it to
encrypt/decrypt data at rest.
!!! warning "JSON Encryptors Must Preserve Keys"
JSON encryptors **must not add or remove keys** from the input dict.
Only values may be transformed. This constraint is **enforced at runtime
by the server** and exists because SQL JSONB merge operations (used for
partial updates) work at the key level.
**Correct (per-key encryption):**
```python
# Input: {"secret": "value", "plain": "x"}
# Output: {"secret": "<encrypted>", "plain": "x"} ✓ Keys preserved
```
**Incorrect (key consolidation):**
```python
# Input: {"secret": "value", "plain": "x"}
# Output: {"__encrypted__": "<blob>", "plain": "x"} ✗ Key changed
```
If your encryptor needs to store auxiliary data (DEK, IV, etc.), embed it
within the encrypted value itself, not as separate keys.
???+ example "Basic Usage"
```python
from langgraph_sdk import Encryption, EncryptionContext
my_encryption = Encryption()
SKIP_FIELDS = {"tenant_id", "owner", "thread_id", "assistant_id"}
ENCRYPTED_PREFIX = "encrypted:"
@my_encryption.encrypt.blob
async def encrypt_blob(ctx: EncryptionContext, blob: bytes) -> bytes:
return your_encrypt_bytes(blob)
@my_encryption.decrypt.blob
async def decrypt_blob(ctx: EncryptionContext, blob: bytes) -> bytes:
return your_decrypt_bytes(blob)
@my_encryption.encrypt.json
async def encrypt_json(ctx: EncryptionContext, data: dict) -> dict:
result = {}
for k, v in data.items():
if k in SKIP_FIELDS or v is None:
result[k] = v
else:
result[k] = ENCRYPTED_PREFIX + your_encrypt_string(v)
return result
@my_encryption.decrypt.json
async def decrypt_json(ctx: EncryptionContext, data: dict) -> dict:
result = {}
for k, v in data.items():
if isinstance(v, str) and v.startswith(ENCRYPTED_PREFIX):
result[k] = your_decrypt_string(v[len(ENCRYPTED_PREFIX):])
else:
result[k] = v
return result
```
???+ example "Field-Specific Logic"
The `ctx.model` and `ctx.field` attributes tell you which model type and
specific field is being encrypted, allowing different logic:
```python
@my_encryption.encrypt.json
async def encrypt_json(ctx: EncryptionContext, data: dict) -> dict:
if ctx.field == "metadata":
# Metadata - standard encryption
return encrypt_standard(data)
elif ctx.field == "values":
# Thread values - more sensitive, use stronger encryption
return encrypt_sensitive(data)
else:
return encrypt_standard(data)
```
!!! warning "Model/Field May Differ Between Encrypt and Decrypt"
Data encrypted with one `(model, field)` pair is **not guaranteed**
to be decrypted with the same pair. The server performs SQL JSONB
merges that can move encrypted values between models (e.g., cron
metadata → run metadata). Your decryption logic must handle data
regardless of the `ctx.model` or `ctx.field` values at decrypt time.
**Safe:** Use `ctx.model`/`ctx.field` for logging or metrics only.
**Safe:** Encrypt different keys based on `ctx.field`, but use a
single decrypt handler that decrypts any value with the encrypted
prefix (and passes through plaintext unchanged):
```python
ENCRYPTED_PREFIX = "enc:"
@my_encryption.encrypt.json
async def encrypt_json(ctx: EncryptionContext, data: dict) -> dict:
# Encrypt different keys depending on the field
if ctx.field == "context":
keys_to_encrypt = {"api_key", "secret_token"}
else:
keys_to_encrypt = {"email", "ssn"}
return {
k: ENCRYPTED_PREFIX + encrypt(v) if k in keys_to_encrypt else v
for k, v in data.items()
}
@my_encryption.decrypt.json
async def decrypt_json(ctx: EncryptionContext, data: dict) -> dict:
# Decrypt ANY value with the prefix, regardless of model/field
return {
k: decrypt(v[len(ENCRYPTED_PREFIX):])
if isinstance(v, str) and v.startswith(ENCRYPTED_PREFIX)
else v
for k, v in data.items()
}
```
**Unsafe:** Using different encryption keys or algorithms based on
`ctx.model`/`ctx.field` will cause decryption failures.
"""
__slots__ = (
"_blob_decryptor",
"_blob_encryptor",
"_context_handler",
"_json_decryptor",
"_json_encryptor",
"decrypt",
"encrypt",
)
types = types
"""Reference to encryption type definitions.
Provides access to all type definitions used in the encryption system,
including EncryptionContext, BlobEncryptor, BlobDecryptor,
JsonEncryptor, and JsonDecryptor.
"""
def __init__(self) -> None:
"""Initialize the Encryption instance."""
_warn_encryption_beta()
self.encrypt = _EncryptDecorators(self)
self.decrypt = _DecryptDecorators(self)
self._blob_encryptor: types.BlobEncryptor | None = None
self._blob_decryptor: types.BlobDecryptor | None = None
self._json_encryptor: types.JsonEncryptor | None = None
self._json_decryptor: types.JsonDecryptor | None = None
self._context_handler: types.ContextHandler | None = None
def context(self, fn: types.ContextHandler) -> types.ContextHandler:
"""Register a context handler to derive encryption context from auth.
The handler receives the authenticated user and current EncryptionContext,
and returns a dict that becomes ctx.metadata for encrypt/decrypt handlers.
This allows encryption context to be derived from JWT claims or other
auth-derived data instead of requiring a separate X-Encryption-Context header.
Note: The context handler is called once per request in middleware,
so ctx.model and ctx.field will be None in the handler.
Example:
```python
from langgraph_sdk import Encryption, EncryptionContext
from starlette.authentication import BaseUser
encryption = Encryption()
@encryption.context
async def get_context(user: BaseUser, ctx: EncryptionContext) -> dict:
# Derive encryption context from authenticated user
return {
**ctx.metadata, # preserve X-Encryption-Context header if present
"tenant_id": user.tenant_id,
}
```
Args:
fn: The context handler function
Returns:
The registered handler function
"""
self._context_handler = fn
return fn
def get_json_encryptor(
self,
_model: str | None = None, # kept for langgraph-api compat
) -> types.JsonEncryptor | None:
"""Get the JSON encryptor.
Args:
_model: Ignored. Kept for backwards compatibility with langgraph-api
which passes model_type to this method.
Returns:
The JSON encryptor, or None if not registered.
"""
return self._json_encryptor
def get_json_decryptor(
self,
_model: str | None = None, # kept for langgraph-api compat
) -> types.JsonDecryptor | None:
"""Get the JSON decryptor.
Args:
_model: Ignored. Kept for backwards compatibility with langgraph-api
which passes model_type to this method.
Returns:
The JSON decryptor, or None if not registered.
"""
return self._json_decryptor
def __repr__(self) -> str:
handlers = []
if self._blob_encryptor:
handlers.append("blob_encryptor")
if self._blob_decryptor:
handlers.append("blob_decryptor")
if self._json_encryptor:
handlers.append("json_encryptor")
if self._json_decryptor:
handlers.append("json_decryptor")
if self._context_handler:
handlers.append("context_handler")
return f"Encryption(handlers=[{', '.join(handlers)}])"

View File

@@ -0,0 +1,147 @@
"""Encryption and decryption types for LangGraph.
This module defines the core types used for custom at-rest encryption
in LangGraph. It includes context types and typed dictionaries for
encryption operations.
"""
from __future__ import annotations
import typing
from collections.abc import Awaitable, Callable
Json = dict[str, typing.Any]
"""JSON-serializable dictionary type for structured data encryption."""
class EncryptionContext:
"""Context passed to encryption/decryption handlers.
Contains arbitrary non-secret key-values that will be stored on encrypt.
These key-values are intended to be sent to an external service that
manages keys and handles the actual encryption and decryption of data.
Attributes:
model: The model type being encrypted (e.g., "assistant", "thread", "run", "checkpoint")
field: The specific field being encrypted (e.g., "metadata", "context", "kwargs", "values")
metadata: Additional context metadata that can be used for encryption decisions
"""
__slots__ = ("field", "metadata", "model")
def __init__(
self,
model: str | None = None,
metadata: dict[str, typing.Any] | None = None,
field: str | None = None,
):
self.model = model
self.field = field
self.metadata = metadata or {}
def __repr__(self) -> str:
return f"EncryptionContext(model={self.model!r}, field={self.field!r}, metadata={self.metadata!r})"
BlobEncryptor = Callable[[EncryptionContext, bytes], Awaitable[bytes]]
"""Handler for encrypting opaque blob data like checkpoints.
Note: Must be an async function. Encryption typically involves I/O operations
(calling external KMS services), which should be async.
Args:
ctx: Encryption context with model type and metadata
blob: The raw bytes to encrypt
Returns:
Awaitable that resolves to encrypted bytes
"""
BlobDecryptor = Callable[[EncryptionContext, bytes], Awaitable[bytes]]
"""Handler for decrypting opaque blob data like checkpoints.
Note: Must be an async function. Decryption typically involves I/O operations
(calling external KMS services), which should be async.
Args:
ctx: Encryption context with model type and metadata
blob: The encrypted bytes to decrypt
Returns:
Awaitable that resolves to decrypted bytes
"""
JsonEncryptor = Callable[[EncryptionContext, Json], Awaitable[Json]]
"""Handler for encrypting structured JSON data.
Note: Must be an async function. Encryption typically involves I/O operations
(calling external KMS services), which should be async.
Used for encrypting structured data like metadata, context, kwargs, values,
and other JSON-serializable fields across different model types.
Maps plaintext fields to encrypted fields. A practical approach:
- Keep "owner" field unencrypted for search/filtering
- Encrypt VALUES (not keys) for fields with specific prefix (e.g., "my.customer.org/")
- Pass through all other fields unencrypted
Example:
Input: {"owner": "user123", "my.customer.org/email": "john@example.com", "tenant_id": "t-456"}
Output: {"owner": "user123", "my.customer.org/email": "ENCRYPTED", "tenant_id": "t-456"}
Note: Encrypted field VALUES cannot be reliably searched, as most real-world
encryption implementations use nonces (non-deterministic encryption).
Only unencrypted fields can be used in search queries.
Args:
ctx: Encryption context with model type, field name, and metadata
data: The plaintext JSON dictionary
Returns:
Awaitable that resolves to encrypted JSON dictionary
"""
JsonDecryptor = Callable[[EncryptionContext, Json], Awaitable[Json]]
"""Handler for decrypting structured JSON data.
Note: Must be an async function. Decryption typically involves I/O operations
(calling external KMS services), which should be async.
Inverse of JsonEncryptor. Must be able to decrypt data that
was encrypted by the corresponding encryptor.
Args:
ctx: Encryption context with model type, field name, and metadata
data: The encrypted JSON dictionary
Returns:
Awaitable that resolves to decrypted JSON dictionary
"""
if typing.TYPE_CHECKING:
from starlette.authentication import BaseUser
ContextHandler = Callable[
["BaseUser", EncryptionContext], Awaitable[dict[str, typing.Any]]
]
"""Handler for deriving encryption context from authenticated user info.
Note: Must be an async function as it may involve I/O operations.
The context handler is called once per request in middleware (after auth),
allowing encryption context to be derived from JWT claims, user properties,
or other auth-derived data instead of requiring a separate X-Encryption-Context header.
The return value becomes ctx.metadata for subsequent encrypt/decrypt operations
and is persisted with encrypted data for later decryption.
Note: ctx.model and ctx.field will be None in context handlers since
the handler runs once per request before any specific model/field is known.
Args:
user: The authenticated user (from Starlette's AuthenticationMiddleware)
ctx: Current encryption context with metadata from X-Encryption-Context header
Returns:
Awaitable that resolves to dict that becomes the new ctx.metadata
"""

View File

@@ -0,0 +1,231 @@
from __future__ import annotations
import logging
import sys
from typing import Any, Literal, cast
import httpx
import orjson
logger = logging.getLogger(__name__)
class LangGraphError(Exception):
pass
class APIError(httpx.HTTPStatusError, LangGraphError):
message: str
request: httpx.Request
body: object | None
code: str | None
param: str | None
type: str | None
def __init__(
self,
message: str,
response_or_request: httpx.Response | httpx.Request,
*,
body: object | None,
) -> None:
if isinstance(response_or_request, httpx.Response):
req = response_or_request.request
response = response_or_request
else:
req = response_or_request
response = None
httpx.HTTPStatusError.__init__(self, message, request=req, response=response) # type: ignore[arg-type]
LangGraphError.__init__(self, message)
self.request = req
self.message = message
self.body = body
if isinstance(body, dict):
b = cast("dict[str, Any]", body)
# Best-effort extraction of common fields if present
code_val = b.get("code")
self.code = code_val if isinstance(code_val, str) else None
param_val = b.get("param")
self.param = param_val if isinstance(param_val, str) else None
t = b.get("type")
self.type = t if isinstance(t, str) else None
else:
self.code = None
self.param = None
self.type = None
class APIResponseValidationError(APIError):
response: httpx.Response
status_code: int
def __init__(
self,
response: httpx.Response,
body: object | None,
*,
message: str | None = None,
) -> None:
super().__init__(
message or "Data returned by API invalid for expected schema.",
response,
body=body,
)
self.response = response
self.status_code = response.status_code
class APIStatusError(APIError):
response: httpx.Response
status_code: int
request_id: str | None
def __init__(
self, message: str, *, response: httpx.Response, body: object | None
) -> None:
super().__init__(message, response, body=body)
self.response = response
self.status_code = response.status_code
self.request_id = response.headers.get("x-request-id")
class APIConnectionError(APIError):
def __init__(
self, *, message: str = "Connection error.", request: httpx.Request
) -> None:
super().__init__(message, response_or_request=request, body=None)
class APITimeoutError(APIConnectionError):
def __init__(self, request: httpx.Request) -> None:
super().__init__(message="Request timed out.", request=request)
class BadRequestError(APIStatusError):
status_code: Literal[400] = 400
class AuthenticationError(APIStatusError):
status_code: Literal[401] = 401
class PermissionDeniedError(APIStatusError):
status_code: Literal[403] = 403
class NotFoundError(APIStatusError):
status_code: Literal[404] = 404
class ConflictError(APIStatusError):
status_code: Literal[409] = 409
class UnprocessableEntityError(APIStatusError):
status_code: Literal[422] = 422
class RateLimitError(APIStatusError):
status_code: Literal[429] = 429
class InternalServerError(APIStatusError):
pass
def _extract_error_message(body: object | None, fallback: str) -> str:
if isinstance(body, dict):
b = cast("dict[str, Any]", body)
for key in ("message", "detail", "error"):
val = b.get(key)
if isinstance(val, str) and val:
return val
# Sometimes errors are structured like {"error": {"message": "..."}}
err = b.get("error")
if isinstance(err, dict):
e = cast("dict[str, Any]", err)
for key in ("message", "detail"):
val = e.get(key)
if isinstance(val, str) and val:
return val
return fallback
async def _adecode_error_body(r: httpx.Response) -> object | None:
try:
data = await r.aread()
except Exception:
return None
if not data:
return None
try:
return orjson.loads(data)
except Exception:
try:
return data.decode()
except Exception:
return None
def _decode_error_body(r: httpx.Response) -> object | None:
try:
data = r.read()
except Exception:
return None
if not data:
return None
try:
return orjson.loads(data)
except Exception:
try:
return data.decode()
except Exception:
return None
def _map_status_error(response: httpx.Response, body: object | None) -> APIStatusError:
status = response.status_code
reason = response.reason_phrase or "HTTP Error"
message = _extract_error_message(body, f"{status} {reason}")
if status == 400:
return BadRequestError(message, response=response, body=body)
if status == 401:
return AuthenticationError(message, response=response, body=body)
if status == 403:
return PermissionDeniedError(message, response=response, body=body)
if status == 404:
return NotFoundError(message, response=response, body=body)
if status == 409:
return ConflictError(message, response=response, body=body)
if status == 422:
return UnprocessableEntityError(message, response=response, body=body)
if status == 429:
return RateLimitError(message, response=response, body=body)
if status >= 500:
return InternalServerError(message, response=response, body=body)
return APIStatusError(message, response=response, body=body)
async def _araise_for_status_typed(r: httpx.Response) -> None:
if r.status_code < 400:
return
body = await _adecode_error_body(r)
err = _map_status_error(r, body)
# Log for older Python versions without Exception notes
if not (sys.version_info >= (3, 11)):
logger.error(f"Error from langgraph-api: {getattr(err, 'message', '')}")
raise err
def _raise_for_status_typed(r: httpx.Response) -> None:
if r.status_code < 400:
return
body = _decode_error_body(r)
err = _map_status_error(r, body)
if not (sys.version_info >= (3, 11)):
logger.error(f"Error from langgraph-api: {getattr(err, 'message', '')}")
raise err

View File

@@ -0,0 +1,238 @@
from __future__ import annotations
import sys
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Generic, Literal, TypeVar
if sys.version_info >= (3, 13):
ContextT = TypeVar("ContextT", default=None)
else:
ContextT = TypeVar("ContextT")
if sys.version_info >= (3, 12):
from typing import TypeAliasType
else:
from typing_extensions import TypeAliasType
from langgraph_sdk.auth.types import BaseUser
if TYPE_CHECKING:
from langgraph.store.base import BaseStore
__all__ = [
"AccessContext",
"ServerRuntime",
]
AccessContext = Literal[
"threads.create_run",
"threads.update",
"threads.read",
"assistants.read",
]
@dataclass(kw_only=True, slots=True, frozen=True)
class _ServerRuntimeBase(Generic[ContextT]):
"""Base for server runtime variants.
!!! warning "Beta"
This API is in beta and may change in future releases.
"""
access_context: AccessContext
"""Why the graph factory is being called.
The server accesses graphs in several contexts beyond just executing runs.
For example, it calls the graph factory to retrieve schemas, render the
graph structure, or read state history. This field tells you which
operation triggered the current call.
In all contexts, the returned graph must have the same topology (nodes,
edges, state schema) as the graph used for execution. Use
`.execution_runtime` to conditionally set up expensive *resources*
(MCP servers, DB connections) without changing the graph structure.
Write contexts (graph is used to write state):
- `threads.create_run` (`graph.astream`) — full graph execution
(nodes + edges). `context` is available (use `.execution_runtime`
to narrow).
- `threads.update` (`graph.aupdate_state`) — does NOT execute node
functions or evaluate edges. Only runs the node's channel writers
to apply the provided values to state channels as if the specified
node had returned them. Reducers are applied and channel triggers
are set, so the next `invoke`/`stream` call will evaluate edges
from that node to determine the next step. Does not need access to
external resources, but a different graph topology will apply
writes to the wrong channels.
Read state contexts (graph used to format the returned
`StateSnapshot`). A different topology may cause `get_state` to
report incorrect pending tasks. Note that `useStream` uses the state
history endpoint to render interrupts and support branching:
- `threads.read` (`graph.aget_state`, `graph.aget_state_history`) —
the graph structure informs which tasks to include in the prepared
view of the latest checkpoint and how to process subgraphs.
Introspection contexts (graph structure only, no execution).
A different topology may cause schemas and visualizations to not
match actual execution:
- `assistants.read` (`graph.aget_graph`, `graph.aget_subgraphs`,
`graph.aget_schemas`) — return the graph definition, subgraph
definitions, and input/output/config schemas. Used for
visualization in the studio UI and to populate schemas for MCP,
A2A, and other protocol integrations.
"""
user: BaseUser | None = field(default=None)
"""The authenticated user, or `None` if no custom auth is configured."""
store: BaseStore
"""Store for the graph run, enabling persistence and memory."""
@property
def execution_runtime(self) -> _ExecutionRuntime[ContextT] | None:
"""Narrow to the execution runtime, or `None` if not in an execution context.
When the server calls the graph factory for `threads.create_run`, the returned
object provides access to `context` (typed by the graph's
`context_schema`). For all other access contexts (introspection, state
reads, state updates), this returns `None`.
Use this to conditionally set up expensive resources (MCP tool servers,
database connections, etc.) that are only needed during execution:
```python
import contextlib
from langgraph_sdk.runtime import ServerRuntime
@contextlib.asynccontextmanager
async def my_factory(runtime: ServerRuntime[MyCtx]):
if ert := runtime.execution_runtime:
# Only connect to MCP servers when actually executing a run.
# Introspection calls (get_schema, get_graph, ...) skip this.
mcp_tools = await connect_mcp(ert.context.mcp_endpoint)
yield create_agent(model, tools=mcp_tools)
await disconnect_mcp()
else:
yield create_agent(model, tools=[])
```
"""
if isinstance(self, _ExecutionRuntime):
return self
return None
def ensure_user(self) -> BaseUser:
"""Return the authenticated user, or raise if not available.
When custom auth is configured, `user` is set for all access contexts
(the factory is only called from HTTP handlers where the auth
middleware has already run). This method raises only when no custom
auth is configured.
Raises:
PermissionError: If no user is authenticated.
"""
if self.user is None:
raise PermissionError(
f"No authenticated user available in access_context='{self.access_context}'. "
"Ensure custom auth is configured for the server."
)
return self.user
@dataclass(kw_only=True, slots=True, frozen=True)
class _ExecutionRuntime(_ServerRuntimeBase[ContextT], Generic[ContextT]):
"""Runtime for `threads.create_run` — the graph will be fully executed.
Access this via `.execution_runtime` on `ServerRuntime`. Do not
construct directly.
!!! warning "Beta"
This API is in beta and may change in future releases.
"""
context: ContextT = field(default=None) # type: ignore[assignment]
"""The graph run context, typed by the graph's `context_schema`.
Only available during `threads.create_run`.
"""
@dataclass(kw_only=True, slots=True, frozen=True)
class _ReadRuntime(_ServerRuntimeBase[ContextT], Generic[ContextT]):
"""Runtime for non-execution access contexts.
Used for introspection (`assistants.read`), state operations
(`threads.read`), and state updates (`threads.update`).
No `context` is available.
!!! warning "Beta"
This API is in beta and may change in future releases.
"""
ServerRuntime = TypeAliasType(
"ServerRuntime",
_ExecutionRuntime[ContextT] | _ReadRuntime[ContextT],
type_params=(ContextT,),
)
"""Runtime context passed to graph builder factories within the Agent Server.
Requires version 0.7.30 or later of the agent server.
The server calls your graph factory in multiple contexts: executing runs,
reading state, fetching schemas, and more. `ServerRuntime` provides
the authenticated user, store, and access context for every call. Use
`.execution_runtime` to narrow to the execution variant and access
`context`.
Example — conditionally initialize MCP tools only during execution:
```python
import contextlib
from dataclasses import dataclass
from langchain.agents import create_agent
from langgraph_sdk.runtime import ServerRuntime
from my_agent import connect_mcp, disconnect_mcp
@dataclass
class MyCtx:
mcp_endpoint: str
_readonly_agent = create_agent("anthropic:claude-3-5-haiku", tools=[])
@contextlib.asynccontextmanager
async def my_factory(runtime: ServerRuntime[MyCtx]):
if ert := runtime.execution_runtime:
# Only connect to MCP servers for actual runs.
# Schema / graph introspection calls skip this.
user_id = runtime.ensure_user().identity
mcp_tools = await connect_mcp(ert.context.mcp_endpoint, user_id)
yield create_agent("anthropic:claude-3-5-haiku", tools=mcp_tools)
await disconnect_mcp()
else:
yield _readonly_agent
```
Example — simple factory that ignores context:
```python
from langgraph_sdk.runtime import ServerRuntime
def build_graph(user: BaseUser) -> CompiledGraph:
...
async def my_factory(runtime: ServerRuntime) -> CompiledGraph:
# No generic needed if you don't use context.
return build_graph(runtime.ensure_user())
```
!!! warning "Beta"
This API is in beta and may change in future releases.
"""

View File

@@ -0,0 +1,691 @@
"""Data models for interacting with the LangGraph API."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import Field
from datetime import datetime
from typing import (
Any,
ClassVar,
Literal,
NamedTuple,
Protocol,
TypeAlias,
Union,
)
from typing_extensions import NotRequired, TypedDict
Json = dict[str, Any] | None
"""Represents a JSON-like structure, which can be None or a dictionary with string keys and any values."""
RunStatus = Literal["pending", "running", "error", "success", "timeout", "interrupted"]
"""
Represents the status of a run:
- "pending": The run is waiting to start.
- "running": The run is currently executing.
- "error": The run encountered an error and stopped.
- "success": The run completed successfully.
- "timeout": The run exceeded its time limit.
- "interrupted": The run was manually stopped or interrupted.
"""
ThreadStatus = Literal["idle", "busy", "interrupted", "error"]
"""
Represents the status of a thread:
- "idle": The thread is not currently processing any task.
- "busy": The thread is actively processing a task.
- "interrupted": The thread's execution was interrupted.
- "error": An exception occurred during task processing.
"""
ThreadStreamMode = Literal["run_modes", "lifecycle", "state_update"]
"""
Defines the mode of streaming:
- "run_modes": Stream the same events as the runs on thread, as well as run_done events.
- "lifecycle": Stream only run start/end events.
- "state_update": Stream state updates on the thread.
"""
StreamMode = Literal[
"values",
"messages",
"updates",
"events",
"tasks",
"checkpoints",
"debug",
"custom",
"messages-tuple",
]
"""
Defines the mode of streaming:
- "values": Stream only the values.
- "messages": Stream complete messages.
- "updates": Stream updates to the state.
- "events": Stream events occurring during execution.
- "checkpoints": Stream checkpoints as they are created.
- "tasks": Stream task start and finish events.
- "debug": Stream detailed debug information.
- "custom": Stream custom events.
"""
DisconnectMode = Literal["cancel", "continue"]
"""
Specifies behavior on disconnection:
- "cancel": Cancel the operation on disconnection.
- "continue": Continue the operation even if disconnected.
"""
MultitaskStrategy = Literal["reject", "interrupt", "rollback", "enqueue"]
"""
Defines how to handle multiple tasks:
- "reject": Reject new tasks when busy.
- "interrupt": Interrupt current task for new ones.
- "rollback": Roll back current task and start new one.
- "enqueue": Queue new tasks for later execution.
"""
OnConflictBehavior = Literal["raise", "do_nothing"]
"""
Specifies behavior on conflict:
- "raise": Raise an exception when a conflict occurs.
- "do_nothing": Ignore conflicts and proceed.
"""
OnCompletionBehavior = Literal["delete", "keep"]
"""
Defines action after completion:
- "delete": Delete resources after completion.
- "keep": Retain resources after completion.
"""
Durability = Literal["sync", "async", "exit"]
"""Durability mode for the graph execution.
- `"sync"`: Changes are persisted synchronously before the next step starts.
- `"async"`: Changes are persisted asynchronously while the next step executes.
- `"exit"`: Changes are persisted only when the graph exits."""
All = Literal["*"]
"""Represents a wildcard or 'all' selector."""
IfNotExists = Literal["create", "reject"]
"""
Specifies behavior if the thread doesn't exist:
- "create": Create a new thread if it doesn't exist.
- "reject": Reject the operation if the thread doesn't exist.
"""
PruneStrategy = Literal["delete", "keep_latest"]
"""
Strategy for pruning threads:
- "delete": Remove threads entirely.
- "keep_latest": Prune old checkpoints but keep threads and their latest state.
"""
CancelAction = Literal["interrupt", "rollback"]
"""
Action to take when cancelling the run.
- "interrupt": Simply cancel the run.
- "rollback": Cancel the run. Then delete the run and associated checkpoints.
"""
BulkCancelRunsStatus = Literal["pending", "running", "all"]
"""
Filter runs by status when bulk-cancelling:
- "pending": Cancel only pending runs.
- "running": Cancel only running runs.
- "all": Cancel all runs regardless of status.
"""
AssistantSortBy = Literal[
"assistant_id", "graph_id", "name", "created_at", "updated_at"
]
"""
The field to sort by.
"""
ThreadSortBy = Literal[
"thread_id", "status", "created_at", "updated_at", "state_updated_at"
]
"""
The field to sort by.
"""
CronSortBy = Literal[
"cron_id",
"assistant_id",
"thread_id",
"created_at",
"updated_at",
"next_run_date",
"end_time",
]
"""
The field to sort by.
"""
SortOrder = Literal["asc", "desc"]
"""
The order to sort by.
"""
class Config(TypedDict, total=False):
"""Configuration options for a call."""
tags: list[str]
"""
Tags for this call and any sub-calls (eg. a Chain calling an LLM).
You can use these to filter calls.
"""
recursion_limit: int
"""
Maximum number of times a call can recurse. If not provided, defaults to 25.
"""
configurable: dict[str, Any]
"""
Runtime values for attributes previously made configurable on this Runnable,
or sub-Runnables, through .configurable_fields() or .configurable_alternatives().
Check .output_schema() for a description of the attributes that have been made
configurable.
"""
class Checkpoint(TypedDict):
"""Represents a checkpoint in the execution process."""
thread_id: str
"""Unique identifier for the thread associated with this checkpoint."""
checkpoint_ns: str
"""Namespace for the checkpoint; used internally to manage subgraph state."""
checkpoint_id: str | None
"""Optional unique identifier for the checkpoint itself."""
checkpoint_map: dict[str, Any] | None
"""Optional dictionary containing checkpoint-specific data."""
class GraphSchema(TypedDict):
"""Defines the structure and properties of a graph."""
graph_id: str
"""The ID of the graph."""
input_schema: dict | None
"""The schema for the graph input.
Missing if unable to generate JSON schema from graph."""
output_schema: dict | None
"""The schema for the graph output.
Missing if unable to generate JSON schema from graph."""
state_schema: dict | None
"""The schema for the graph state.
Missing if unable to generate JSON schema from graph."""
config_schema: dict | None
"""The schema for the graph config.
Missing if unable to generate JSON schema from graph."""
context_schema: dict | None
"""The schema for the graph context.
Missing if unable to generate JSON schema from graph."""
Subgraphs = dict[str, GraphSchema]
class AssistantBase(TypedDict):
"""Base model for an assistant."""
assistant_id: str
"""The ID of the assistant."""
graph_id: str
"""The ID of the graph."""
config: Config
"""The assistant config."""
context: Context
"""The static context of the assistant."""
created_at: datetime
"""The time the assistant was created."""
metadata: Json
"""The assistant metadata."""
version: int
"""The version of the assistant"""
name: str
"""The name of the assistant"""
description: str | None
"""The description of the assistant"""
class AssistantVersion(AssistantBase):
"""Represents a specific version of an assistant."""
pass
class Assistant(AssistantBase):
"""Represents an assistant with additional properties."""
updated_at: datetime
"""The last time the assistant was updated."""
class AssistantsSearchResponse(TypedDict):
"""Paginated response for assistant search results."""
assistants: list[Assistant]
"""The assistants returned for the current search page."""
next: str | None
"""Pagination cursor from the ``X-Pagination-Next`` response header."""
class Interrupt(TypedDict):
"""Represents an interruption in the execution flow."""
value: Any
"""The value associated with the interrupt."""
id: str
"""The ID of the interrupt. Can be used to resume the interrupt."""
class Thread(TypedDict):
"""Represents a conversation thread."""
thread_id: str
"""The ID of the thread."""
created_at: datetime
"""The time the thread was created."""
updated_at: datetime
"""The last time the thread was updated."""
metadata: Json
"""The thread metadata."""
status: ThreadStatus
"""The status of the thread, one of 'idle', 'busy', 'interrupted'."""
values: Json
"""The current state of the thread."""
interrupts: dict[str, list[Interrupt]]
"""Mapping of task ids to interrupts that were raised in that task."""
extracted: NotRequired[dict[str, Any]]
"""Extracted values from thread data. Only present when `extract` is used in search."""
class ThreadTask(TypedDict):
"""Represents a task within a thread."""
id: str
name: str
error: str | None
interrupts: list[Interrupt]
checkpoint: Checkpoint | None
state: ThreadState | None
result: dict[str, Any] | None
class ThreadState(TypedDict):
"""Represents the state of a thread."""
values: list[dict] | dict[str, Any]
"""The state values."""
next: Sequence[str]
"""The next nodes to execute. If empty, the thread is done until new input is
received."""
checkpoint: Checkpoint
"""The ID of the checkpoint."""
metadata: Json
"""Metadata for this state"""
created_at: str | None
"""Timestamp of state creation"""
parent_checkpoint: Checkpoint | None
"""The ID of the parent checkpoint. If missing, this is the root checkpoint."""
tasks: Sequence[ThreadTask]
"""Tasks to execute in this step. If already attempted, may contain an error."""
interrupts: list[Interrupt]
"""Interrupts which were thrown in this thread."""
class ThreadUpdateStateResponse(TypedDict):
"""Represents the response from updating a thread's state."""
checkpoint: Checkpoint
"""Checkpoint of the latest state."""
class Run(TypedDict):
"""Represents a single execution run."""
run_id: str
"""The ID of the run."""
thread_id: str
"""The ID of the thread."""
assistant_id: str
"""The assistant that was used for this run."""
created_at: datetime
"""The time the run was created."""
updated_at: datetime
"""The last time the run was updated."""
status: RunStatus
"""The status of the run. One of 'pending', 'running', "error", 'success', "timeout", "interrupted"."""
metadata: Json
"""The run metadata."""
multitask_strategy: MultitaskStrategy
"""Strategy to handle concurrent runs on the same thread."""
class Cron(TypedDict):
"""Represents a scheduled task."""
cron_id: str
"""The ID of the cron."""
assistant_id: str
"""The ID of the assistant."""
thread_id: str | None
"""The ID of the thread."""
on_run_completed: OnCompletionBehavior | None
"""What to do with the thread after the run completes. Only applicable for stateless crons."""
end_time: datetime | None
"""The end date to stop running the cron."""
schedule: str
"""The schedule to run, cron format."""
created_at: datetime
"""The time the cron was created."""
updated_at: datetime
"""The last time the cron was updated."""
payload: dict
"""The run payload to use for creating new run."""
user_id: str | None
"""The user ID of the cron."""
next_run_date: datetime | None
"""The next run date of the cron."""
metadata: dict
"""The metadata of the cron."""
enabled: bool
"""Whether the cron is enabled."""
class CronUpdate(TypedDict, total=False):
"""Payload for updating a cron job. All fields are optional."""
schedule: str
"""The cron schedule to execute this job on."""
end_time: datetime
"""The end date to stop running the cron."""
input: Input
"""The input to the graph."""
metadata: dict[str, Any]
"""Metadata to assign to the cron job runs."""
config: Config
"""The configuration for the assistant."""
context: Context
"""Static context added to the assistant."""
webhook: str
"""Webhook to call after LangGraph API call is done."""
interrupt_before: All | list[str]
"""Nodes to interrupt immediately before they get executed."""
interrupt_after: All | list[str]
"""Nodes to interrupt immediately after they get executed."""
on_run_completed: OnCompletionBehavior
"""What to do with the thread after the run completes."""
enabled: bool
"""Enable or disable the cron job."""
stream_mode: StreamMode | list[StreamMode]
"""The stream mode(s) to use."""
stream_subgraphs: bool
"""Whether to stream output from subgraphs."""
stream_resumable: bool
"""Whether to persist the stream chunks in order to resume the stream later."""
durability: Durability
"""Durability level for the run. Must be one of 'sync', 'async', or 'exit'."""
# Select field aliases for client-side typing of `select` parameters.
# These mirror the server's allowed field sets.
AssistantSelectField = Literal[
"assistant_id",
"graph_id",
"name",
"description",
"config",
"context",
"created_at",
"updated_at",
"metadata",
"version",
]
ThreadSelectField = Literal[
"thread_id",
"created_at",
"updated_at",
"metadata",
"config",
"context",
"status",
"values",
"interrupts",
]
RunSelectField = Literal[
"run_id",
"thread_id",
"assistant_id",
"created_at",
"updated_at",
"status",
"metadata",
"kwargs",
"multitask_strategy",
]
CronSelectField = Literal[
"cron_id",
"assistant_id",
"thread_id",
"end_time",
"schedule",
"created_at",
"updated_at",
"user_id",
"payload",
"next_run_date",
"metadata",
"now",
"on_run_completed",
"enabled",
]
PrimitiveData = str | int | float | bool | None
QueryParamTypes = (
Mapping[str, PrimitiveData | Sequence[PrimitiveData]]
| list[tuple[str, PrimitiveData]]
| tuple[tuple[str, PrimitiveData], ...]
| str
| bytes
)
class RunCreate(TypedDict):
"""Defines the parameters for initiating a background run."""
thread_id: str | None
"""The identifier of the thread to run. If not provided, the run is stateless."""
assistant_id: str
"""The identifier of the assistant to use for this run."""
input: dict | None
"""Initial input data for the run."""
metadata: dict | None
"""Additional metadata to associate with the run."""
config: Config | None
"""Configuration options for the run."""
context: Context | None
"""The static context of the run."""
checkpoint_id: str | None
"""The identifier of a checkpoint to resume from."""
interrupt_before: list[str] | None
"""List of node names to interrupt execution before."""
interrupt_after: list[str] | None
"""List of node names to interrupt execution after."""
webhook: str | None
"""URL to send webhook notifications about the run's progress."""
multitask_strategy: MultitaskStrategy | None
"""Strategy for handling concurrent runs on the same thread."""
class Item(TypedDict):
"""Represents a single document or data entry in the graph's Store.
Items are used to store cross-thread memories.
"""
namespace: list[str]
"""The namespace of the item. A namespace is analogous to a document's directory."""
key: str
"""The unique identifier of the item within its namespace.
In general, keys needn't be globally unique.
"""
value: dict[str, Any]
"""The value stored in the item. This is the document itself."""
created_at: datetime
"""The timestamp when the item was created."""
updated_at: datetime
"""The timestamp when the item was last updated."""
class ListNamespaceResponse(TypedDict):
"""Response structure for listing namespaces."""
namespaces: list[list[str]]
"""A list of namespace paths, where each path is a list of strings."""
class SearchItem(Item, total=False):
"""Item with an optional relevance score from search operations.
Attributes:
score (Optional[float]): Relevance/similarity score. Included when
searching a compatible store with a natural language query.
"""
score: float | None
class SearchItemsResponse(TypedDict):
"""Response structure for searching items."""
items: list[SearchItem]
"""A list of items matching the search criteria."""
class StreamPart(NamedTuple):
"""Represents a part of a stream response."""
event: str
"""The type of event for this stream part."""
data: dict
"""The data payload associated with the event."""
id: str | None = None
"""The ID of the event."""
class Send(TypedDict):
"""Represents a message to be sent to a specific node in the graph.
This type is used to explicitly send messages to nodes in the graph, typically
used within Command objects to control graph execution flow.
"""
node: str
"""The name of the target node to send the message to."""
input: dict[str, Any] | None
"""Optional dictionary containing the input data to be passed to the node.
If None, the node will be called with no input."""
class Command(TypedDict, total=False):
"""Represents one or more commands to control graph execution flow and state.
This type defines the control commands that can be returned by nodes to influence
graph execution. It lets you navigate to other nodes, update graph state,
and resume from interruptions.
"""
goto: Send | str | Sequence[Send | str]
"""Specifies where execution should continue. Can be:
- A string node name to navigate to
- A Send object to execute a node with specific input
- A sequence of node names or Send objects to execute in order
"""
update: dict[str, Any] | Sequence[tuple[str, Any]]
"""Updates to apply to the graph's state. Can be:
- A dictionary of state updates to merge
- A sequence of (key, value) tuples for ordered updates
"""
resume: Any
"""Value to resume execution with after an interruption.
Used in conjunction with interrupt() to implement control flow.
"""
class RunCreateMetadata(TypedDict):
"""Metadata for a run creation request."""
run_id: str
"""The ID of the run."""
thread_id: str | None
"""The ID of the thread."""
class _TypedDictLikeV1(Protocol):
"""Protocol to represent types that behave like TypedDicts
Version 1: using `ClassVar` for keys."""
__required_keys__: ClassVar[frozenset[str]]
__optional_keys__: ClassVar[frozenset[str]]
class _TypedDictLikeV2(Protocol):
"""Protocol to represent types that behave like TypedDicts
Version 2: not using `ClassVar` for keys."""
__required_keys__: frozenset[str]
__optional_keys__: frozenset[str]
class _DataclassLike(Protocol):
"""Protocol to represent types that behave like dataclasses.
Inspired by the private _DataclassT from dataclasses that uses a similar protocol as a bound.
"""
__dataclass_fields__: ClassVar[dict[str, Field[Any]]]
class _BaseModelLike(Protocol):
"""Protocol to represent types that behave like Pydantic `BaseModel`."""
model_config: ClassVar[dict[str, Any]]
__pydantic_core_schema__: ClassVar[Any]
def model_dump(
self,
**kwargs: Any,
) -> dict[str, Any]: ...
_JSONLike: TypeAlias = None | str | int | float | bool
_JSONMap: TypeAlias = Mapping[
str, Union[_JSONLike, list[_JSONLike], "_JSONMap", list["_JSONMap"]]
]
Input: TypeAlias = (
_TypedDictLikeV1 | _TypedDictLikeV2 | _DataclassLike | _BaseModelLike | _JSONMap
)
Context: TypeAlias = Input

View File

@@ -0,0 +1,157 @@
"""Adapted from httpx_sse to split lines on \n, \r, \r\n per the SSE spec."""
from __future__ import annotations
import contextlib
from collections.abc import AsyncIterator, Iterator
from typing import cast
import httpx
import orjson
from langgraph_sdk.schema import StreamPart
BytesLike = bytes | bytearray | memoryview
class BytesLineDecoder:
"""
Handles incrementally reading lines from text.
Has the same behaviour as the stdllib bytes splitlines,
but handling the input iteratively.
"""
def __init__(self) -> None:
self.buffer = bytearray()
self.trailing_cr: bool = False
def decode(self, text: bytes) -> list[BytesLike]:
# See https://docs.python.org/3/glossary.html#term-universal-newlines
NEWLINE_CHARS = b"\n\r"
# We always push a trailing `\r` into the next decode iteration.
if self.trailing_cr:
text = b"\r" + text
self.trailing_cr = False
if text.endswith(b"\r"):
self.trailing_cr = True
text = text[:-1]
if not text:
# NOTE: the edge case input of empty text doesn't occur in practice,
# because other httpx internals filter out this value
return [] # pragma: no cover
trailing_newline = text[-1] in NEWLINE_CHARS
lines = cast(list[BytesLike], text.splitlines())
if len(lines) == 1 and not trailing_newline:
# No new lines, buffer the input and continue.
self.buffer.extend(lines[0])
return []
if self.buffer:
# Include any existing buffer in the first portion of the
# splitlines result.
self.buffer.extend(lines[0])
lines = cast(list[BytesLike], [self.buffer, *lines[1:]])
self.buffer = bytearray()
if not trailing_newline:
# If the last segment of splitlines is not newline terminated,
# then drop it from our output and start a new buffer.
self.buffer.extend(lines.pop())
return lines
def flush(self) -> list[BytesLike]:
if not self.buffer and not self.trailing_cr:
return []
lines = [self.buffer]
self.buffer = bytearray()
self.trailing_cr = False
return lines
class SSEDecoder:
def __init__(self) -> None:
self._event = ""
self._data = bytearray()
self._last_event_id = ""
self._retry: int | None = None
@property
def last_event_id(self) -> str | None:
"""Return the last event identifier that was seen."""
return self._last_event_id or None
def decode(self, line: bytes) -> StreamPart | None:
# See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
if not line:
if (
not self._event
and not self._data
and not self._last_event_id
and self._retry is None
):
return None
sse = StreamPart(
event=self._event,
data=orjson.loads(self._data) if self._data else None, # type: ignore[invalid-argument-type]
id=self.last_event_id,
)
# NOTE: as per the SSE spec, do not reset last_event_id.
self._event = ""
self._data = bytearray()
self._retry = None
return sse
if line.startswith(b":"):
return None
fieldname, _, value = line.partition(b":")
if value.startswith(b" "):
value = value[1:]
if fieldname == b"event":
self._event = value.decode()
elif fieldname == b"data":
self._data.extend(value)
elif fieldname == b"id":
if b"\0" in value:
pass
else:
self._last_event_id = value.decode()
elif fieldname == b"retry":
with contextlib.suppress(TypeError, ValueError):
self._retry = int(value)
else:
pass # Field is ignored.
return None
async def aiter_lines_raw(response: httpx.Response) -> AsyncIterator[BytesLike]:
decoder = BytesLineDecoder()
async for chunk in response.aiter_bytes():
for line in decoder.decode(chunk):
yield line
for line in decoder.flush():
yield line
def iter_lines_raw(response: httpx.Response) -> Iterator[BytesLike]:
decoder = BytesLineDecoder()
for chunk in response.iter_bytes():
for line in decoder.decode(chunk):
yield line
for line in decoder.flush():
yield line