Compare commits

...
Author SHA1 Message Date
William FHandGitHub 4d01e69b82 release(checkpoint-postgres): 3.0.1 (#6568) 2025-12-09 23:05:49 +00:00
William FHandGitHub e86b5f4da2 chore: pgqs (#6567)
Add more argument sanitization
2025-12-09 14:51:29 -08:00
Eugene YurtsevandGitHub b70d5aac0e release(checkpoint-sqlite): 3.0.1 (#6566)
Release 3.0.1
2025-12-09 17:00:00 -05:00
Eugene YurtsevandGitHub 297242913f fix(checkpoint-sqlite): harden (#6565)
harden
2025-12-09 16:47:55 -05:00
Mason DaughertyandGitHub 02965fb5f5 chore: revise CONTRIBUTING.md (#6561) 2025-12-09 20:33:11 +00:00
Mason DaughertyandGitHub 64f0458296 chore: nits (#6560)
- Delete `security.md` as it will be inherited from
`langchain-ai/.github`
- Move `CONTRIBUTING.md` to `.github` (will still appear on homepage),
cleaning up top level
- Copy contents of `AGENTS.md` to `CLAUDE.md` since CC still doesn't
take `AGENTS.md`
2025-12-09 15:23:59 -05:00
Andrew NguonlyandGitHub 76711536f9 feat(sdk-py): Add name parameter to Assistants count API (#6558)
**Description**: The Agent Server API now supports counting assistants
by name.

This is similar to adding the `name` parameter to the Assistants search
API: https://github.com/langchain-ai/langgraph/pull/6483
2025-12-09 11:09:12 -08:00
William FHandGitHub 6c6978918e chore(cli): Pass through webhook configuration in dev server (#6557) 2025-12-09 07:05:15 -08:00
William FHandGitHub 2f1a16006a release(cli): 0.4.8 (#6556)
For webhook configuration support
2025-12-09 13:25:02 +00:00
William FHandGitHub 269d08f5d3 feat(cli): webhook configuration (#6555) 2025-12-09 13:17:22 +00:00
37 changed files with 2560 additions and 1414 deletions
+6
View File
@@ -0,0 +1,6 @@
# Contributing to LangGraph
Hi there! Thank you for even being interested in contributing to LangGraph.
As an open-source project in a rapidly developing field, we are extremely open to contributions, whether they involve new features, improved infrastructure, better documentation, or bug fixes.
To learn how to contribute to LangGraph, please follow the [contribution guide here](https://docs.langchain.com/oss/python/contributing).
+2 -2
View File
@@ -116,13 +116,13 @@ jobs:
strategy:
matrix:
python-version:
- "3.11"
- "3.13"
steps:
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: astral-sh/setup-uv@v7
with:
python-version: "3.11"
python-version: "3.13"
enable-cache: true
cache-suffix: "schema-check-cli"
- name: Install CLI dependencies
+55
View File
@@ -0,0 +1,55 @@
# AGENTS Instructions
This repository is a monorepo. Each library lives in a subdirectory under `libs/`.
When you modify code in any library, run the following commands in that library's directory before creating a pull request:
- `make format` run code formatters
- `make lint` run the linter
- `make test` execute the test suite
To run a particular test file or to pass additional pytest options you can specify the `TEST` variable:
```
TEST=path/to/test.py make test
```
Other pytest arguments can also be supplied inside the `TEST` variable.
## Libraries
The repository contains several Python and JavaScript/TypeScript libraries.
Below is a high-level overview:
- **checkpoint** base interfaces for LangGraph checkpointers.
- **checkpoint-postgres** Postgres implementation of the checkpoint saver.
- **checkpoint-sqlite** SQLite implementation of the checkpoint saver.
- **cli** official command-line interface for LangGraph.
- **langgraph** core framework for building stateful, multi-actor agents.
- **prebuilt** high-level APIs for creating and running agents and tools.
- **sdk-js** JS/TS SDK for interacting with the LangGraph REST API.
- **sdk-py** Python SDK for the LangGraph Server API.
### Dependency map
The diagram below lists downstream libraries for each production dependency as
declared in that library's `pyproject.toml` (or `package.json`).
```text
checkpoint
├── checkpoint-postgres
├── checkpoint-sqlite
├── prebuilt
└── langgraph
prebuilt
└── langgraph
sdk-py
├── langgraph
└── cli
sdk-js (standalone)
```
Changes to a library may impact all of its dependents shown above.
-293
View File
@@ -1,293 +0,0 @@
# Contributing to LangGraph
Thank you for being interested in contributing to LangGraph!
## General guidelines
Here are some things to keep in mind for all types of contributions:
- Follow the ["fork and pull request"](https://docs.github.com/en/get-started/exploring-projects-on-github/contributing-to-a-project) workflow.
- Fill out the checked-in pull request template when opening pull requests. Note related issues and tag relevant maintainers.
- Ensure your PR passes formatting, linting, and testing checks before requesting a review.
- If you would like comments or feedback, please tag a maintainer.
- Backwards compatibility is key. Your changes must not be breaking, except in case of critical bug and security fixes.
- Look for duplicate PRs or issues that have already been opened before opening a new one.
- Keep scope as isolated as possible. As a general rule, your changes should not affect more than one package at a time.
### Bugfixes
For bug fixes, please open up an issue before proposing a fix to ensure the proposal properly addresses the underlying problem. In general, bug fixes should all have an accompanying unit test that fails before the fix.
### New features
For new features, please start a new [discussion](https://forum.langchain.com/), where the maintainers will help with scoping out the necessary changes.
## Contribute Documentation
Documentation is a vital part of LangGraph. We welcome both new documentation for new features and
community improvements to our current documentation. Please read the resources below before getting started:
- [Documentation style guide](#documentation-style-guide)
- [Documentation setup](#setup)
## Documentation Style Guide
As LangGraph continues to grow, the surface area of documentation required to cover it continues to grow too.
This page provides guidelines for anyone writing documentation for LangGraph, as well as some of our philosophies around organization and structure.
## Philosophy
LangGraph's documentation follows the [Diataxis framework](https://diataxis.fr).
Under this framework, all documentation falls under one of four categories: [Tutorials](#tutorials),
[How-to guides](#how-to-guides),
[References](#references), and [Explanations (aka conceptual guides)](#conceptual-guide).
### Tutorials
Tutorials are lessons that take the reader through a practical activity. Their purpose is to help the user
gain understanding of concepts and how they interact by showing one way to achieve some goal in a hands-on way.
They should **avoid** giving
multiple permutations of ways to achieve that goal in-depth. Choice is burdensome. Instead, they should guide a new user through a recommended path to accomplishing a concrete goal. While the end result of a tutorial does not necessarily need to
be completely production-ready, it should be useful and practically satisfy the goal that you clearly stated in the tutorial's introduction.
To quote the Diataxis website:
> A tutorial serves the users *acquisition* of skills and knowledge - their study. Its purpose is not to help the user get something done, but to help them learn.
In LangGraph, these are often higher level guides that show off end-to-end use cases.
Some examples include:
- [Build a Customer Support Bot](https://langchain-ai.github.io/langgraph/tutorials/customer-support/customer-support/)
- [Build a SQL Agent](https://langchain-ai.github.io/langgraph/tutorials/sql/sql-agent/)
Here are some high-level tips on writing a good tutorial:
- Focus on guiding the user to get something done, but keep in mind the end-goal is more to impart principles than to create a perfect production system.
- Be specific, not abstract and follow one path.
- No need to go deeply into alternative approaches, but its ok to reference them, ideally with a link to an appropriate how-to guide.
- Get "a point on the board" as soon as possible - something the user can run that outputs something.
- You can iterate and expand afterwards.
- Try to frequently checkpoint at given steps where the user can run code and see progress.
- Focus on results, not technical explanation.
- Crosslink heavily to appropriate conceptual/reference pages
- The first time you mention a LangGraph concept, use its full name (e.g. "human-in-the-loop"), and link to its conceptual/other documentation page.
- It's also helpful to add a prerequisite callout that links to any pages with necessary background information.
- End with a recap/next steps section summarizing what the tutorial covered and future reading, such as related how-to guides.
- Use phrases like "Next we can run X & Y. We will expect Z.". Then afterwards, use language like "Notice Z" that recalls our expectations and directs the reader's attention to the topic we are trying to teach.
- Do not shy away from repetition.
### How-to guides
A how-to guide, as the name implies, demonstrates how to do something discrete and specific.
It should assume that the user is already familiar with underlying concepts, and is trying to solve an immediate problem, but
should still give some background or list the scenarios where the information contained within can be relevant.
They can and should discuss alternatives if one approach may be better than another in certain cases.
To quote the Diataxis website:
> A how-to guide serves the work of the already-competent user, whom you can assume to know what they want to do, and to be able to follow your instructions correctly.
Some examples include:
- [How to add persistence to your graph](https://langchain-ai.github.io/langgraph/how-tos/persistence/)
- [How to view and update past graph state](https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/time-travel/)
Here are some high-level tips on writing a good how-to guide:
- Clearly explain what you are guiding the user through at the start
- Assume higher intent than a tutorial and show what the user needs to do to get that task done
- Assume familiarity of concepts, but explain why suggested actions are helpful
- Crosslink heavily to conceptual/reference pages
- Discuss alternatives and responses to real-world tradeoffs that may arise when solving a problem
- Use lots of example code, ideally within complete code blocks that the reader can copy and run.
- End with a recap/next steps section summarizing what the tutorial covered and future reading, such as other related how-to guides
### Conceptual guides
LangGraph's conceptual guides fall under the **Explanation** quadrant of Diataxis. They should cover LangChain terms and concepts
in a more abstract way than how-to guides or tutorials, and should be geared towards curious users interested in
gaining a deeper understanding of the framework. Try to avoid excessively large code examples. The goal here is to
impart perspective to the user rather than to finish a practical project. These guides should cover **why** things work the way they do.
To quote the Diataxis website:
> The perspective of explanation is higher and wider than that of the other types. It does not take the users eye-level view, as in a how-to guide, or a close-up view of the machinery, like reference material. Its scope in each case is a topic - “an area of knowledge”, that somehow has to be bounded in a reasonable, meaningful way.
Some examples include:
- [What does it mean to be agentic?](https://langchain-ai.github.io/langgraph/concepts/high_level/)
- [Tool calling](https://langchain-ai.github.io/langgraph/concepts/agentic_concepts/#tool-calling)
Here are some high-level tips on writing a good conceptual guide:
- Explain design decisions. Why does concept X exist and why was it designed this way?
- Use analogies and reference other concepts and alternatives
- Avoid blending in too much reference content
- You can and should reference content covered in other guides, but make sure to link to them
### References
References contain detailed, low-level information that describes exactly what functionality exists and how to use it.
In LangGraph, this is mainly our API reference pages, which are populated from docstrings within code.
References pages are generally not read end-to-end, but are consulted as necessary when a user needs to know
how to use something specific.
To quote the Diataxis website:
> The only purpose of a reference guide is to describe, as succinctly as possible, and in an orderly way. Whereas the content of tutorials and how-to guides are led by needs of the user, reference material is led by the product it describes.
Many of the reference pages in LangChain are automatically generated from code,
but here are some high-level tips on writing a good docstring:
- Be concise
- Discuss special cases and deviations from a user's expectations
- Go into detail on required inputs and outputs
- Light details on when one might use the feature are fine, but in-depth details belong in other sections.
Each category serves a distinct purpose and requires a specific approach to writing and structuring the content.
## General guidelines
Here are some other guidelines you should think about when writing and organizing documentation.
We generally do not merge new tutorials from outside contributors without an actual need.
We welcome updates as well as new integration docs, how-tos, and references.
### Avoid duplication
Multiple pages that cover the same material in depth are difficult to maintain and cause confusion. There should
be only one (very rarely two), canonical pages for a given concept or feature. Instead, you should link to other guides.
### Link to other sections
Because sections of the docs do not exist in a vacuum, it is important to link to other sections as often as possible
to allow a developer to learn more about an unfamiliar topic inline.
This includes linking to the API references as well as conceptual sections!
### Be concise
In general, take a less-is-more approach. If a section with a good explanation of a concept already exists, you should link to it rather than
re-explain it, unless the concept you are documenting presents some new wrinkle.
Be concise, including in code samples.
### General style
- Use active voice and present tense whenever possible
- Use examples and code snippets to illustrate concepts and usage
- Use appropriate header levels (`#`, `##`, `###`, etc.) to organize the content hierarchically
- Use fewer cells with more code to make copy/paste easier
- Use bullet points and numbered lists to break down information into easily digestible chunks
- Use tables (especially for **Reference** sections) and diagrams often to present information visually
- Include the table of contents for longer documentation pages to help readers navigate the content, but hide it for shorter pages
## Setup
LangGraph documentation consists of two components:
1. Main Documentation: Hosted at [https://langchain-ai.github.io/langgraph/](https://langchain-ai.github.io/langgraph/),
this comprehensive resource serves as the primary user-facing documentation.
It covers a wide array of topics, including tutorials, use cases, integrations,
and more, offering extensive guidance on building with LangGraph.
The content for this documentation lives in the `/docs` directory of the monorepo.
2. In-code Documentation: This is documentation of the codebase itself, which is also
used to generate the externally facing [API Reference](https://langchain-ai.github.io/langgraph/reference/graphs/).
The content for the API reference is autogenerated by scanning the docstrings in the codebase. For this reason we ask that developers document their code well.
We appreciate all contributions to the documentation, whether it be fixing a typo,
adding a new tutorial or example and whether it be in the main documentation or the API Reference.
### 📜 Main Documentation
The content for the main documentation is located in the `/docs` directory of the monorepo.
The documentation is written using a combination of ipython notebooks (`.ipynb` files)
and markdown (`.md` files). The notebooks are converted to markdown
and then built using [MkDocs](https://www.mkdocs.org/).
Feel free to make contributions to the main documentation! 🥰
After modifying the documentation:
1. Run the linting and formatting commands (see below) to ensure that the documentation is well-formatted and free of errors.
2. Optionally build the documentation locally to verify that the changes look good.
3. Make a pull request with the changes.
### ⚒️ Linting and Building Documentation Locally
After writing up the documentation, you may want to lint and build the documentation
locally to ensure that it looks good and is free of errors.
If you're unable to build it locally that's okay as well, as you will be able to
see a preview of the documentation on the pull request page.
From the **monorepo root**, run the following command to install the dependencies:
<!-- TODO -->
```bash
poetry install --with docs --no-root
```
#### Building
The code that builds the documentation is located in the `/docs` directory of the monorepo.
Before building the documentation, it is always a good idea to clean the build directory:
```bash
make clean-docs
```
You can build and preview the documentation as outlined below:
```bash
make serve-docs
```
#### Linting
To spell check the docs, run the following from the `docs` directory:
```bash
codespell --skip="*.ambr,*.lock,*.ipynb,*.yaml,*.zlib,*.css.map,*.js.map" --ignore-words-list="infor,thead,stdio,nd,jupyter,lets,lite,uis,deque" .
```
### In-code Documentation
The in-code documentation is autogenerated from docstrings.
For the API reference to be useful, the codebase must be well-documented. This means that all functions, classes, and methods should have a docstring that explains what they do, what the arguments are, and what the return value is. This is a good practice in general, but it is especially important for LangGraph because the API reference is the primary resource for developers to understand how to use the codebase.
We generally follow the [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) for docstrings.
Here is an example of a well-documented function:
```python
def my_function(arg1: int, arg2: str) -> float:
"""This is a short description of the function. (It should be a single sentence.)
This is a longer description of the function. It should explain what
the function does, what the arguments are, and what the return value is.
It should wrap at 88 characters.
Examples:
This is a section for examples of how to use the function.
```python
my_function(1, "hello")
\```
Args:
arg1: This is a description of arg1. We do not need to specify the type since
it is already specified in the function signature.
arg2: This is a description of arg2.
Returns:
This is a description of the return value.
"""
return 3.14
```
@@ -143,11 +143,13 @@ class PostgresSaver(BasePostgresSaver):
"""
where, args = self._search_where(config, filter, before)
query = self.SELECT_SQL + where + " ORDER BY checkpoint_id DESC"
if limit:
query += f" LIMIT {limit}"
params = list(args)
if limit is not None:
query += " LIMIT %s"
params.append(int(limit))
# if we change this to use .stream() we need to make sure to close the cursor
with self._cursor() as cur:
cur.execute(query, args)
cur.execute(query, params)
values = cur.fetchall()
if not values:
return
@@ -132,11 +132,13 @@ class AsyncPostgresSaver(BasePostgresSaver):
"""
where, args = self._search_where(config, filter, before)
query = self.SELECT_SQL + where + " ORDER BY checkpoint_id DESC"
if limit:
query += f" LIMIT {limit}"
params = list(args)
if limit is not None:
query += " LIMIT %s"
params.append(int(limit))
# if we change this to use .stream() we need to make sure to close the cursor
async with self._cursor() as cur:
await cur.execute(query, args, binary=True)
await cur.execute(query, params, binary=True)
values = await cur.fetchall()
if not values:
return
@@ -272,10 +272,12 @@ class ShallowPostgresSaver(BasePostgresSaver):
"""
where, args = self._search_where(config, filter, before)
query = self.SELECT_SQL + where
if limit:
query += f" LIMIT {limit}"
params = list(args)
if limit is not None:
query += " LIMIT %s"
params.append(int(limit))
with self._cursor() as cur:
cur.execute(self.SELECT_SQL + where, args, binary=True)
cur.execute(query, params, binary=True)
for value in cur:
checkpoint: Checkpoint = {
**value["checkpoint"],
@@ -636,10 +638,12 @@ class AsyncShallowPostgresSaver(BasePostgresSaver):
"""
where, args = self._search_where(config, filter, before)
query = self.SELECT_SQL + where
if limit:
query += f" LIMIT {limit}"
params = list(args)
if limit is not None:
query += " LIMIT %s"
params.append(int(limit))
async with self._cursor() as cur:
await cur.execute(self.SELECT_SQL + where, args, binary=True)
await cur.execute(query, params, binary=True)
async for value in cur:
checkpoint: Checkpoint = {
**value["checkpoint"],
@@ -266,6 +266,27 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
k: v(self) if v is not None and callable(v) else v
for k, v in migration.params.items()
}
if "dims" in params:
try:
params["dims"] = int(params["dims"])
except Exception as e:
raise ValueError(
f"Invalid dims for vector index: {params['dims']}"
) from e
if "vector_type" in params:
vt = str(params["vector_type"])
if vt not in ("vector", "halfvec"):
raise ValueError(
f"Invalid vector_type for pgvector: {vt}"
)
params["vector_type"] = vt
if "index_type" in params:
it = str(params["index_type"])
if it not in ("hnsw", "ivfflat"):
raise ValueError(
f"Invalid index_type for pgvector: {it}"
)
params["index_type"] = it
sql = sql % params
await cur.execute(sql)
await cur.execute(
@@ -327,31 +327,36 @@ class BasePostgresStore(Generic[C]):
embedding_request: tuple[str, Sequence[tuple[str, str, str, str]]] | None = None
if inserts:
values = []
insertion_params = []
insertion_params: list[Any] = []
vector_values = []
embedding_request_params = []
# Handle TTL expiration
# First handle main store insertions
for op in inserts:
if op.ttl is not None:
expires_at_str = f"NOW() + INTERVAL '{op.ttl * 60} seconds'"
ttl_minutes = op.ttl
else:
expires_at_str = "NULL"
ttl_minutes = None
values.append(
f"(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, {expires_at_str}, %s)"
)
insertion_params.extend(
[
(
_namespace_to_text(op.namespace),
op.key,
Jsonb(cast(dict, op.value)),
ttl_minutes,
]
)
)
if op.ttl is not None:
values.append(
"(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NOW() + %s::interval, %s)"
)
ttl_minutes = float(op.ttl)
insertion_params.extend(
(
f"{ttl_minutes * 60} seconds",
ttl_minutes,
)
)
else:
values.append(
"(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, %s)"
)
insertion_params.append(None)
# Then handle embeddings if configured
if self.index_config:
@@ -465,6 +470,10 @@ class BasePostgresStore(Generic[C]):
cast(dict, self.index_config)["dims"],
)
else:
if vector_type not in ("vector", "halfvec"):
raise ValueError(
f"Invalid vector_type for pgvector: {vector_type}"
)
score_operator = score_operator % ("%s", vector_type)
vectors_per_doc_estimate = cast(dict, self.index_config)[
@@ -1122,6 +1131,27 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
k: v(self) if v is not None and callable(v) else v
for k, v in migration.params.items()
}
if "dims" in params:
try:
params["dims"] = int(params["dims"])
except Exception as e:
raise ValueError(
f"Invalid dims for vector index: {params['dims']}"
) from e
if "vector_type" in params:
vt = str(params["vector_type"])
if vt not in ("vector", "halfvec"):
raise ValueError(
f"Invalid vector_type for pgvector: {vt}"
)
params["vector_type"] = vt
if "index_type" in params:
it = str(params["index_type"])
if it not in ("hnsw", "ivfflat"):
raise ValueError(
f"Invalid index_type for pgvector: {it}"
)
params["index_type"] = it
sql = sql % params
cur.execute(sql)
cur.execute("INSERT INTO vector_migrations (v) VALUES (%s)", (v,))
@@ -1175,15 +1205,44 @@ def _get_vector_type_ops(store: BasePostgresStore) -> str:
def _get_index_params(store: Any) -> tuple[str, dict[str, Any]]:
"""Get the index type and configuration based on config."""
"""Get a sanitized index type and configuration based on config.
Only allow known-safe kinds and integer parameters to avoid SQL injection
when constructing DDL strings for index creation.
"""
if not store.index_config:
return "hnsw", {}
config = cast(PostgresIndexConfig, store.index_config)
index_config = config.get("ann_index_config", _DEFAULT_ANN_CONFIG).copy()
kind = index_config.pop("kind", "hnsw")
index_config.pop("vector_type", None)
return kind, index_config
raw = config.get("ann_index_config", _DEFAULT_ANN_CONFIG).copy()
kind = str(raw.pop("kind", "hnsw"))
if kind not in ("hnsw", "ivfflat", "flat"):
raise ValueError(
f"Invalid index kind for pgvector: {kind}. Expected 'hnsw', 'ivfflat', or 'flat'."
)
raw.pop("vector_type", None)
if kind == "hnsw":
allowed_keys = {"m", "ef_construction"}
else: # ivfflat/flat
allowed_keys = {"lists", "nlist"}
sanitized: dict[str, int] = {}
for k, v in list(raw.items()):
if k not in allowed_keys:
continue
key = "lists" if k == "nlist" else k
try:
ivalue = int(v) # type: ignore[call-overload]
except Exception as e:
raise ValueError(f"Invalid index parameter value for {k}: {v}") from e
if ivalue <= 0:
continue
sanitized[key] = ivalue
return kind, sanitized
def _namespace_to_text(
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint-postgres"
version = "3.0.1"
version = "3.0.2"
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
authors = []
requires-python = ">=3.10"
+564 -416
View File
File diff suppressed because it is too large Load Diff
@@ -329,8 +329,9 @@ class SqliteSaver(BaseCheckpointSaver[str]):
FROM checkpoints
{where}
ORDER BY checkpoint_id DESC"""
if limit:
query += f" LIMIT {limit}"
if limit is not None:
query += " LIMIT ?"
param_values = (*param_values, limit)
with self.cursor(transaction=False) as cur, closing(self.conn.cursor()) as wcur:
cur.execute(query, param_values)
for (
@@ -425,8 +425,9 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
FROM checkpoints
{where}
ORDER BY checkpoint_id DESC"""
if limit:
query += f" LIMIT {limit}"
if limit is not None:
query += " LIMIT ?"
params = (*params, limit)
async with (
self.lock,
self.conn.execute(query, params) as cur,
@@ -1,12 +1,32 @@
from __future__ import annotations
import json
import re
from collections.abc import Sequence
from typing import Any
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import get_checkpoint_id
_FILTER_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+$")
def _validate_filter_key(key: str) -> None:
"""Validate that a filter key is safe for use in SQL queries.
Args:
key: The filter key to validate
Raises:
ValueError: If the key contains invalid characters that could enable SQL injection
"""
# Allow alphanumeric characters, underscores, dots, and hyphens
# This covers typical JSON property names while preventing SQL injection
if not _FILTER_PATTERN.match(key):
raise ValueError(
f"Invalid filter key: '{key}'. Filter keys must contain only alphanumeric characters, underscores, dots, and hyphens."
)
def _metadata_predicate(
metadata_filter: dict[str, Any],
@@ -43,6 +63,7 @@ def _metadata_predicate(
# process metadata query
for query_key, query_value in metadata_filter.items():
_validate_filter_key(query_key)
operator, param_value = _where_value(query_value)
predicates.append(
f"json_extract(CAST(metadata AS TEXT), '$.{query_key}') {operator}"
@@ -107,6 +107,9 @@ def _decode_ns_text(namespace: str) -> tuple[str, ...]:
return tuple(namespace.split("."))
_FILTER_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+$")
def _validate_filter_key(key: str) -> None:
"""Validate that a filter key is safe for use in SQL queries.
@@ -118,7 +121,7 @@ def _validate_filter_key(key: str) -> None:
"""
# Allow alphanumeric characters, underscores, dots, and hyphens
# This covers typical JSON property names while preventing SQL injection
if not re.match(r"^[a-zA-Z0-9_.-]+$", key):
if not _FILTER_PATTERN.match(key):
raise ValueError(
f"Invalid filter key: '{key}'. Filter keys must contain only alphanumeric characters, underscores, dots, and hyphens."
)
@@ -404,12 +407,9 @@ class BaseSqliteStore:
# SQLite json_extract returns unquoted string values
if isinstance(value, str):
filter_conditions.append(
"json_extract(value, '$."
+ key
+ "') = '"
+ value.replace("'", "''")
+ "'"
"json_extract(value, '$." + key + "') = ?"
)
filter_params.append(value)
elif value is None:
filter_conditions.append(
"json_extract(value, '$." + key + "') IS NULL"
@@ -423,9 +423,11 @@ class BaseSqliteStore:
+ ("1" if value else "0")
)
elif isinstance(value, (int, float)):
# Use parameterized query to handle special floats and large integers
filter_conditions.append(
"json_extract(value, '$." + key + "') = " + str(value)
"json_extract(value, '$." + key + "') = ?"
)
filter_params.append(float(value))
else:
# Complex objects (list, dict, …) compare JSON text
filter_conditions.append(
@@ -636,85 +638,66 @@ class BaseSqliteStore:
# We need to properly format values for SQLite JSON extraction comparison
if op == "$eq":
if isinstance(value, str):
# Direct string comparison with proper quoting for unquoted json_extract result
return (
f"json_extract(value, '$.{key}') = '"
+ value.replace("'", "''")
+ "'",
[],
)
return f"json_extract(value, '$.{key}') = ?", [value]
elif value is None:
return f"json_extract(value, '$.{key}') IS NULL", []
elif isinstance(value, bool):
# SQLite JSON stores booleans as integers
return f"json_extract(value, '$.{key}') = {1 if value else 0}", []
elif isinstance(value, (int, float)):
return f"json_extract(value, '$.{key}') = {value}", []
# Convert to float to handle inf, -inf, nan, and very large integers
# SQLite REAL can handle these cases better than INTEGER
return f"json_extract(value, '$.{key}') = ?", [float(value)]
else:
return f"json_extract(value, '$.{key}') = ?", [orjson.dumps(value)]
elif op == "$gt":
# For numeric values, SQLite needs to compare as numbers, not strings
if isinstance(value, (int, float)):
return f"CAST(json_extract(value, '$.{key}') AS REAL) > {value}", []
# Convert to float to handle special values and very large integers
return f"CAST(json_extract(value, '$.{key}') AS REAL) > ?", [
float(value)
]
elif isinstance(value, str):
return (
f"json_extract(value, '$.{key}') > '"
+ value.replace("'", "''")
+ "'",
[],
)
return f"json_extract(value, '$.{key}') > ?", [value]
else:
return f"json_extract(value, '$.{key}') > ?", [orjson.dumps(value)]
elif op == "$gte":
if isinstance(value, (int, float)):
return f"CAST(json_extract(value, '$.{key}') AS REAL) >= {value}", []
return f"CAST(json_extract(value, '$.{key}') AS REAL) >= ?", [
float(value)
]
elif isinstance(value, str):
return (
f"json_extract(value, '$.{key}') >= '"
+ value.replace("'", "''")
+ "'",
[],
)
return f"json_extract(value, '$.{key}') >= ?", [value]
else:
return f"json_extract(value, '$.{key}') >= ?", [orjson.dumps(value)]
elif op == "$lt":
if isinstance(value, (int, float)):
return f"CAST(json_extract(value, '$.{key}') AS REAL) < {value}", []
return f"CAST(json_extract(value, '$.{key}') AS REAL) < ?", [
float(value)
]
elif isinstance(value, str):
return (
f"json_extract(value, '$.{key}') < '"
+ value.replace("'", "''")
+ "'",
[],
)
return f"json_extract(value, '$.{key}') < ?", [value]
else:
return f"json_extract(value, '$.{key}') < ?", [orjson.dumps(value)]
elif op == "$lte":
if isinstance(value, (int, float)):
return f"CAST(json_extract(value, '$.{key}') AS REAL) <= {value}", []
return f"CAST(json_extract(value, '$.{key}') AS REAL) <= ?", [
float(value)
]
elif isinstance(value, str):
return (
f"json_extract(value, '$.{key}') <= '"
+ value.replace("'", "''")
+ "'",
[],
)
return f"json_extract(value, '$.{key}') <= ?", [value]
else:
return f"json_extract(value, '$.{key}') <= ?", [orjson.dumps(value)]
elif op == "$ne":
if isinstance(value, str):
return (
f"json_extract(value, '$.{key}') != '"
+ value.replace("'", "''")
+ "'",
[],
)
return f"json_extract(value, '$.{key}') != ?", [value]
elif value is None:
return f"json_extract(value, '$.{key}') IS NOT NULL", []
elif isinstance(value, bool):
return f"json_extract(value, '$.{key}') != {1 if value else 0}", []
elif isinstance(value, (int, float)):
return f"json_extract(value, '$.{key}') != {value}", []
# Convert to float for consistency
return f"json_extract(value, '$.{key}') != ?", [float(value)]
else:
return f"json_extract(value, '$.{key}') != ?", [orjson.dumps(value)]
else:
@@ -792,8 +775,9 @@ class SqliteStore(BaseSqliteStore, BaseStore):
self,
conn: sqlite3.Connection,
*,
deserializer: Callable[[bytes | str | orjson.Fragment], dict[str, Any]]
| None = None,
deserializer: (
Callable[[bytes | str | orjson.Fragment], dict[str, Any]] | None
) = None,
index: SqliteIndexConfig | None = None,
ttl: TTLConfig | None = None,
):
@@ -874,85 +858,66 @@ class SqliteStore(BaseSqliteStore, BaseStore):
# We need to properly format values for SQLite JSON extraction comparison
if op == "$eq":
if isinstance(value, str):
# Direct string comparison with proper quoting for unquoted json_extract result
return (
f"json_extract(value, '$.{key}') = '"
+ value.replace("'", "''")
+ "'",
[],
)
return f"json_extract(value, '$.{key}') = ?", [value]
elif value is None:
return f"json_extract(value, '$.{key}') IS NULL", []
elif isinstance(value, bool):
# SQLite JSON stores booleans as integers
return f"json_extract(value, '$.{key}') = {1 if value else 0}", []
elif isinstance(value, (int, float)):
return f"json_extract(value, '$.{key}') = {value}", []
# Convert to float to handle inf, -inf, nan, and very large integers
# SQLite REAL can handle these cases better than INTEGER
return f"json_extract(value, '$.{key}') = ?", [float(value)]
else:
return f"json_extract(value, '$.{key}') = ?", [orjson.dumps(value)]
elif op == "$gt":
# For numeric values, SQLite needs to compare as numbers, not strings
if isinstance(value, (int, float)):
return f"CAST(json_extract(value, '$.{key}') AS REAL) > {value}", []
# Convert to float to handle special values and very large integers
return f"CAST(json_extract(value, '$.{key}') AS REAL) > ?", [
float(value)
]
elif isinstance(value, str):
return (
f"json_extract(value, '$.{key}') > '"
+ value.replace("'", "''")
+ "'",
[],
)
return f"json_extract(value, '$.{key}') > ?", [value]
else:
return f"json_extract(value, '$.{key}') > ?", [orjson.dumps(value)]
elif op == "$gte":
if isinstance(value, (int, float)):
return f"CAST(json_extract(value, '$.{key}') AS REAL) >= {value}", []
return f"CAST(json_extract(value, '$.{key}') AS REAL) >= ?", [
float(value)
]
elif isinstance(value, str):
return (
f"json_extract(value, '$.{key}') >= '"
+ value.replace("'", "''")
+ "'",
[],
)
return f"json_extract(value, '$.{key}') >= ?", [value]
else:
return f"json_extract(value, '$.{key}') >= ?", [orjson.dumps(value)]
elif op == "$lt":
if isinstance(value, (int, float)):
return f"CAST(json_extract(value, '$.{key}') AS REAL) < {value}", []
return f"CAST(json_extract(value, '$.{key}') AS REAL) < ?", [
float(value)
]
elif isinstance(value, str):
return (
f"json_extract(value, '$.{key}') < '"
+ value.replace("'", "''")
+ "'",
[],
)
return f"json_extract(value, '$.{key}') < ?", [value]
else:
return f"json_extract(value, '$.{key}') < ?", [orjson.dumps(value)]
elif op == "$lte":
if isinstance(value, (int, float)):
return f"CAST(json_extract(value, '$.{key}') AS REAL) <= {value}", []
return f"CAST(json_extract(value, '$.{key}') AS REAL) <= ?", [
float(value)
]
elif isinstance(value, str):
return (
f"json_extract(value, '$.{key}') <= '"
+ value.replace("'", "''")
+ "'",
[],
)
return f"json_extract(value, '$.{key}') <= ?", [value]
else:
return f"json_extract(value, '$.{key}') <= ?", [orjson.dumps(value)]
elif op == "$ne":
if isinstance(value, str):
return (
f"json_extract(value, '$.{key}') != '"
+ value.replace("'", "''")
+ "'",
[],
)
return f"json_extract(value, '$.{key}') != ?", [value]
elif value is None:
return f"json_extract(value, '$.{key}') IS NOT NULL", []
elif isinstance(value, bool):
return f"json_extract(value, '$.{key}') != {1 if value else 0}", []
elif isinstance(value, (int, float)):
return f"json_extract(value, '$.{key}') != {value}", []
# Convert to float for consistency
return f"json_extract(value, '$.{key}') != ?", [float(value)]
else:
return f"json_extract(value, '$.{key}') != ?", [orjson.dumps(value)]
else:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint-sqlite"
version = "3.0.0"
version = "3.0.1"
description = "Library with a SQLite implementation of LangGraph checkpoint saver."
authors = []
requires-python = ">=3.10"
+75 -1
View File
@@ -113,4 +113,78 @@ class TestAsyncSqliteSaver:
search_results_5[1].config["configurable"]["checkpoint_ns"],
} == {"", "inner"}
# TODO: test before and limit params
# Test limit param
search_results_6 = [
c
async for c in saver.alist(
{"configurable": {"thread_id": "thread-2"}}, limit=1
)
]
assert len(search_results_6) == 1
assert search_results_6[0].config["configurable"]["thread_id"] == "thread-2"
# Test before param
search_results_7 = [
c async for c in saver.alist(None, before=search_results_5[1].config)
]
assert len(search_results_7) == 1
assert search_results_7[0].config["configurable"]["thread_id"] == "thread-1"
async def test_limit_parameter_sql_injection_prevention(self) -> None:
"""Test that the limit parameter properly uses parameterized queries to prevent SQL injection."""
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
# Setup: Create multiple checkpoints
for i in range(5):
config: RunnableConfig = {
"configurable": {
"thread_id": f"thread-{i}",
"checkpoint_ns": "",
}
}
checkpoint = empty_checkpoint()
metadata: CheckpointMetadata = {"index": i}
await saver.aput(config, checkpoint, metadata, {})
# Test that limit works correctly with valid integer
results = [c async for c in saver.alist(None, limit=2)]
assert len(results) == 2
# Test that limit=0 returns no results
results = [c async for c in saver.alist(None, limit=0)]
assert len(results) == 0
# Test that limit=None returns all results
results = [c async for c in saver.alist(None, limit=None)]
assert len(results) == 5
# Test explicit SQL injection attempt via limit parameter
# Even if type checking is bypassed and a malicious string is passed,
# the parameterized query will treat it as a value, not SQL code
# This would cause an error (can't convert string to int for LIMIT),
# which is the correct secure behavior
malicious_limits = [
"1; DROP TABLE checkpoints; --",
"1 OR 1=1",
"999999 UNION SELECT * FROM checkpoints",
]
for malicious_limit in malicious_limits:
# The parameterized query should safely reject non-integer limits
# or convert them in a way that prevents SQL injection
try:
# Bypass type checking by casting
results = [
c
async for c in saver.alist(None, limit=malicious_limit) # type: ignore
]
# If it doesn't raise an error, it should at least not execute the injection
# SQLite's parameter binding will try to convert the string to an integer
# which will either fail or treat it as 0
except Exception:
# Expected: SQLite should reject invalid limit values
pass
# Verify the checkpoints table still exists and has all data
# (would have been dropped if injection succeeded)
results = [c async for c in saver.alist(None, limit=None)]
assert len(results) == 5
+125
View File
@@ -182,3 +182,128 @@ class TestSqliteSaver:
with pytest.raises(NotImplementedError, match="AsyncSqliteSaver"):
async for _ in saver.alist(self.config_1):
pass
def test_metadata_predicate_sql_injection_prevention(self) -> None:
"""Test that _metadata_predicate rejects malicious filter keys."""
# Test various SQL injection payloads
malicious_keys = [
"x') OR '1'='1", # Boolean-based injection
"x') OR 1=1 --", # Comment-based injection
"x') UNION SELECT 1,2,3,4,5,6,7 --", # UNION-based injection
"access') = 'public' OR '1'='1' OR json_extract(value, '$.", # Complex injection
"'; DROP TABLE checkpoints; --", # Destructive injection
]
for malicious_key in malicious_keys:
with pytest.raises(ValueError, match="Invalid filter key"):
_metadata_predicate({malicious_key: "dummy"})
def test_checkpoint_search_sql_injection_prevention(self) -> None:
"""Test that SQL injection via malicious filter keys is prevented in checkpoint search."""
with SqliteSaver.from_conn_string(":memory:") as saver:
# Setup: Create checkpoints with different metadata
config_public: RunnableConfig = {
"configurable": {
"thread_id": "thread-public",
"checkpoint_ns": "",
}
}
config_private: RunnableConfig = {
"configurable": {
"thread_id": "thread-private",
"checkpoint_ns": "",
}
}
checkpoint_public = empty_checkpoint()
checkpoint_private = empty_checkpoint()
metadata_public: CheckpointMetadata = {
"access": "public",
"data": "public information",
}
metadata_private: CheckpointMetadata = {
"access": "private",
"data": "secret information",
"password": "secret123",
}
saver.put(config_public, checkpoint_public, metadata_public, {})
saver.put(config_private, checkpoint_private, metadata_private, {})
# Normal query - should return only public checkpoint
normal_results = list(saver.list(None, filter={"access": "public"}))
assert len(normal_results) == 1
assert normal_results[0].metadata["access"] == "public"
# SQL injection attempt should raise ValueError
malicious_key = (
"access') = 'public' OR '1'='1' OR json_extract(metadata, '$."
)
with pytest.raises(ValueError, match="Invalid filter key"):
list(saver.list(None, filter={malicious_key: "dummy"}))
def test_limit_parameter_sql_injection_prevention(self) -> None:
"""Test that the limit parameter properly uses parameterized queries to prevent SQL injection."""
with SqliteSaver.from_conn_string(":memory:") as saver:
# Setup: Create multiple checkpoints
for i in range(5):
config: RunnableConfig = {
"configurable": {
"thread_id": f"thread-{i}",
"checkpoint_ns": "",
}
}
checkpoint = empty_checkpoint()
metadata: CheckpointMetadata = {"index": i}
saver.put(config, checkpoint, metadata, {})
# Test that limit works correctly with valid integer
results = list(saver.list(None, limit=2))
assert len(results) == 2
# Test that limit=0 returns no results
results = list(saver.list(None, limit=0))
assert len(results) == 0
# Test that limit=None returns all results
results = list(saver.list(None, limit=None))
assert len(results) == 5
def test_metadata_filter_keys_with_hyphens_and_digits(self) -> None:
"""Metadata keys with hyphens and digit-start should be filterable.
This exposes incorrect JSON path handling (unquoted segments) by asserting
that such filters successfully match saved checkpoints.
"""
with SqliteSaver.from_conn_string(":memory:") as saver:
config: RunnableConfig = {
"configurable": {
"thread_id": "thread-hyphen-digit",
"checkpoint_ns": "",
}
}
checkpoint = empty_checkpoint()
metadata: CheckpointMetadata = {
"access-level": "public",
"user": {"access-level": "nested", "123abc": "ok2"},
"123abc": "ok",
}
saver.put(config, checkpoint, metadata, {})
# Top-level hyphenated key
results = list(saver.list(None, filter={"access-level": "public"}))
assert len(results) == 1
# Nested hyphenated key via dotted path
results = list(saver.list(None, filter={"user.access-level": "nested"}))
assert len(results) == 1
# Top-level digit-starting key
results = list(saver.list(None, filter={"123abc": "ok"}))
assert len(results) == 1
# Nested digit-starting key via dotted path
results = list(saver.list(None, filter={"user.123abc": "ok2"}))
assert len(results) == 1
+135
View File
@@ -1069,6 +1069,141 @@ def test_sql_injection_vulnerability(store: SqliteStore) -> None:
store.search(("docs",), filter={malicious_key: "dummy"})
def test_sql_injection_filter_values(store: SqliteStore) -> None:
"""Test that SQL injection via malicious filter values is properly escaped."""
# Setup: Create documents with different access levels
store.put(("docs",), "doc1", {"access": "public", "title": "Public Document"})
store.put(("docs",), "doc2", {"access": "private", "title": "Private Document"})
store.put(("docs",), "doc3", {"access": "secret", "title": "Secret Document"})
# Test 1: Basic SQL injection attempt with single quote
malicious_value = "public' OR '1'='1"
results = store.search(("docs",), filter={"access": malicious_value})
# Should return 0 results because the malicious value is escaped and won't match anything
assert len(results) == 0, "SQL injection via string value should be blocked"
# Test 2: SQL injection with comment
malicious_value = "public'; --"
results = store.search(("docs",), filter={"access": malicious_value})
assert len(results) == 0, "SQL comment injection should be blocked"
# Test 3: UNION injection attempt
malicious_value = "public' UNION SELECT * FROM store --"
results = store.search(("docs",), filter={"access": malicious_value})
assert len(results) == 0, "UNION injection should be blocked"
# Test 4: Parameterized queries handle strings with null bytes and SQL injection attempts safely
malicious_value = "public\x00' OR '1'='1"
results = store.search(("docs",), filter={"access": malicious_value})
assert len(results) == 0, (
"Parameterized queries treat injection attempts as literal strings"
)
# Test 5: Multiple single quotes
malicious_value = "''''"
results = store.search(("docs",), filter={"access": malicious_value})
assert len(results) == 0, "Multiple quotes should be handled safely"
# Test 6: Legitimate value with single quote should work
store.put(("docs",), "doc4", {"title": "O'Brien's Document", "access": "public"})
results = store.search(("docs",), filter={"title": "O'Brien's Document"})
assert len(results) == 1, "Legitimate single quotes should work"
assert results[0].value["title"] == "O'Brien's Document"
# Test 7: Unicode characters with injection attempt
malicious_value = "public' OR 'א'='א"
results = store.search(("docs",), filter={"access": malicious_value})
assert len(results) == 0, "Unicode-based injection should be blocked"
def test_numeric_filter_safety(store: SqliteStore) -> None:
"""Test that numeric filter values are handled safely."""
# Setup: Create documents with numeric fields
store.put(("items",), "item1", {"price": 10, "quantity": 5})
store.put(("items",), "item2", {"price": 20, "quantity": 3})
store.put(("items",), "item3", {"price": 30, "quantity": 1})
# Test 1: Normal numeric comparison
results = store.search(("items",), filter={"price": {"$gt": 15}})
assert len(results) == 2
assert all(r.value["price"] > 15 for r in results)
# Test 2: Special float values (infinity)
results = store.search(("items",), filter={"price": {"$lt": float("inf")}})
assert len(results) == 3, "All finite values should be less than infinity"
# Test 3: Special float values (negative infinity)
results = store.search(("items",), filter={"price": {"$gt": float("-inf")}})
assert len(results) == 3, (
"All finite values should be greater than negative infinity"
)
# Test 4: NaN handling - NaN comparisons should not cause errors
try:
results = store.search(("items",), filter={"price": {"$eq": float("nan")}})
# NaN never equals anything, including itself, so should return 0 results
assert len(results) == 0
except Exception as e:
pytest.fail(f"NaN handling should not raise exception: {e}")
# Test 5: Very large numbers
results = store.search(("items",), filter={"price": {"$lt": 10**100}})
assert len(results) == 3, "Very large numbers should be handled safely"
# Test 6: Negative numbers
store.put(("items",), "item4", {"price": -10, "quantity": 0})
results = store.search(("items",), filter={"price": {"$lt": 0}})
assert len(results) == 1
assert results[0].key == "item4"
def test_boolean_filter_safety(store: SqliteStore) -> None:
"""Test that boolean filter values are handled safely."""
store.put(("flags",), "flag1", {"active": True, "name": "Feature A"})
store.put(("flags",), "flag2", {"active": False, "name": "Feature B"})
store.put(("flags",), "flag3", {"active": True, "name": "Feature C"})
# Test boolean filters
results = store.search(("flags",), filter={"active": True})
assert len(results) == 2
assert all(r.value["active"] is True for r in results)
results = store.search(("flags",), filter={"active": False})
assert len(results) == 1
assert results[0].value["active"] is False
def test_filter_keys_with_hyphens_and_digits(store: SqliteStore) -> None:
"""Keys with hyphens or leading digits should be queryable via filters.
Current unquoted JSON path construction (e.g., '$.access-level' or '$.123abc')
is not valid JSON1 syntax, so this test will catch regressions in path handling.
"""
# Documents with top-level and nested keys requiring bracket-quoted JSON paths
store.put(
("docs",),
"hyphen",
{"access-level": "public", "user": {"access-level": "nested"}},
)
store.put(("docs",), "digit", {"123abc": "ok", "user": {"123abc": "ok2"}})
# Top-level hyphenated key
results = store.search(("docs",), filter={"access-level": "public"})
assert [r.key for r in results] == ["hyphen"]
# Nested hyphenated key via dotted path
results = store.search(("docs",), filter={"user.access-level": "nested"})
assert [r.key for r in results] == ["hyphen"]
# Top-level digit-starting key
results = store.search(("docs",), filter={"123abc": "ok"})
assert [r.key for r in results] == ["digit"]
# Nested digit-starting key via dotted path
results = store.search(("docs",), filter={"user.123abc": "ok2"})
assert [r.key for r in results] == ["digit"]
@pytest.mark.parametrize("distance_type", VECTOR_TYPES)
def test_non_ascii(
fake_embeddings: CharacterEmbeddings,
+2 -2
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.10"
[[package]]
@@ -293,7 +293,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-sqlite"
version = "3.0.0"
version = "3.0.1"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
+1
View File
@@ -0,0 +1 @@
.langgraph_api/
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: test lint format test-integration update-schema
.PHONY: test lint format test-integration update-schema bump-version
######################
# TESTING AND COVERAGE
@@ -35,3 +35,6 @@ format format_diff:
update-schema:
uv run python generate_schema.py
bump-version:
uv run hatch version patch
+4
View File
@@ -27,6 +27,8 @@ from langgraph_cli.schemas import (
StoreConfig,
ThreadTTLConfig,
TTLConfig,
WebhooksConfig,
WebhookUrlPolicy,
)
@@ -118,6 +120,8 @@ def add_descriptions_to_schema(schema, cls):
SerdeConfig,
TTLConfig,
ConfigurableHeaderConfig,
WebhooksConfig,
WebhookUrlPolicy,
]:
if potential_cls.__name__ == def_name:
add_descriptions_to_schema(def_schema, potential_cls)
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.4.7"
__version__ = "0.4.9"
+1
View File
@@ -760,6 +760,7 @@ def dev(
http=config_json.get("http"),
ui=config_json.get("ui"),
ui_config=config_json.get("ui_config"),
webhooks=config_json.get("webhooks"),
studio_url=studio_url,
allow_blocking=allow_blocking,
tunnel=tunnel,
+10
View File
@@ -156,6 +156,8 @@ def validate_config(config: Config) -> Config:
"auth": config.get("auth"),
"encryption": config.get("encryption"),
"http": config.get("http"),
# Pass through webhooks config so it can be injected into the image
"webhooks": config.get("webhooks"),
"checkpointer": config.get("checkpointer"),
"ui": config.get("ui"),
"ui_config": config.get("ui_config"),
@@ -959,6 +961,10 @@ ADD {relpath} /deps/{name}
if (http_config := config.get("http")) is not None:
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
# Inject webhooks configuration if provided
if (webhooks_config := config.get("webhooks")) is not None:
env_vars.append(f"ENV LANGGRAPH_WEBHOOKS='{json.dumps(webhooks_config)}'")
if (checkpointer_config := config.get("checkpointer")) is not None:
env_vars.append(
f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'"
@@ -1085,6 +1091,10 @@ def node_config_to_docker(
if (http_config := config.get("http")) is not None:
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
# Inject webhooks configuration if provided
if (webhooks_config := config.get("webhooks")) is not None:
env_vars.append(f"ENV LANGGRAPH_WEBHOOKS='{json.dumps(webhooks_config)}'")
if (checkpointer_config := config.get("checkpointer")) is not None:
env_vars.append(
f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'"
+49 -2
View File
@@ -362,7 +362,7 @@ class CorsConfig(TypedDict, total=False):
"""
class ConfigurableHeaderConfig(TypedDict):
class ConfigurableHeaderConfig(TypedDict, total=False):
"""Customize which headers to include as configurable values in your runs.
By default, omits x-api-key, x-tenant-id, and x-service-key.
@@ -373,7 +373,7 @@ class ConfigurableHeaderConfig(TypedDict):
"""
includes: list[str] | None
"""Headers to include (if not also matches against an 'exludes' pattern.
"""Headers to include (if not also matched against an 'excludes' pattern).
Examples:
- 'user-agent'
@@ -485,6 +485,46 @@ class HttpConfig(TypedDict, total=False):
"""
class WebhookUrlPolicy(TypedDict, total=False):
require_https: bool
"""Enforce HTTPS scheme for absolute URLs; reject `http://` when true."""
allowed_domains: list[str]
"""Hostname allowlist. Supports exact hosts and wildcard subdomains.
Use entries like "hooks.example.com" or "*.mycorp.com". The wildcard only
matches subdomains ("foo.mycorp.com"), not the apex ("mycorp.com"). When
empty or omitted, any public host is allowed (subject to SSRF IP checks).
"""
allowed_ports: list[int]
"""Explicit port allowlist for absolute URLs.
If set, requests must use one of these ports. Defaults are respected when
a port is not present in the URL (443 for https, 80 for http).
"""
max_url_length: int
"""Maximum permitted URL length in characters; longer inputs are rejected early."""
disable_loopback: bool
"""Disallow relative URLs (internal loopback calls) when true."""
class WebhooksConfig(TypedDict, total=False):
env_prefix: str
"""Required prefix for environment variables referenced in header templates.
Acts as an allowlist boundary to prevent leaking arbitrary environment
variables. Defaults to "LG_WEBHOOK_" when omitted.
"""
url: WebhookUrlPolicy
"""URL validation policy for user-supplied webhook endpoints."""
headers: dict[str, str]
"""Static headers to include with webhook requests.
Values may contain templates of the form "${{ env.VAR }}". On startup, these
are resolved via the process environment after verifying `VAR` starts with
`env_prefix`. Mixed literals and multiple templates are allowed.
"""
class Config(TypedDict, total=False):
"""Top-level config for langgraph-cli or similar deployment tooling."""
@@ -613,6 +653,13 @@ class Config(TypedDict, total=False):
and how cross-origin requests are handled.
"""
webhooks: WebhooksConfig | None
"""Optional. Webhooks configuration for outbound event delivery.
Forwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig`
for URL policy and header templating details.
"""
ui: dict[str, str] | None
"""Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.
"""
+2 -1
View File
@@ -19,7 +19,7 @@ dependencies = [
path = "langgraph_cli/__init__.py"
[project.optional-dependencies]
inmem = [
"langgraph-api>=0.4,<0.6.0 ; python_version >= '3.11'",
"langgraph-api>=0.5.35,<0.6.0 ; python_version >= '3.11'",
"langgraph-runtime-inmem>=0.7 ; python_version >= '3.11'",
"python-dotenv>=0.8.0",
]
@@ -49,6 +49,7 @@ lint = [
dev = [
{include-group = "test"},
{include-group = "lint"},
"hatch>=1.16.2",
]
[tool.uv]
+82 -6
View File
@@ -210,6 +210,17 @@
}
],
"description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n"
},
"webhooks": {
"anyOf": [
{
"$ref": "#/$defs/WebhooksConfig"
},
{
"type": "null"
}
],
"description": "Optional. Webhooks configuration for outbound event delivery.\n\nForwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig`\nfor URL policy and header templating details.\n"
}
},
"required": [
@@ -413,6 +424,17 @@
}
],
"description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n"
},
"webhooks": {
"anyOf": [
{
"$ref": "#/$defs/WebhooksConfig"
},
{
"type": "null"
}
],
"description": "Optional. Webhooks configuration for outbound event delivery.\n\nForwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig`\nfor URL policy and header templating details.\n"
}
},
"required": [
@@ -616,7 +638,7 @@
},
"EncryptionConfig": {
"title": "EncryptionConfig",
"description": "Configuration for custom at-rest encryption logic.\n\n Allows you to implement custom encryption for sensitive data stored in the database,\n including metadata fields and checkpoint blobs.",
"description": "Configuration for custom at-rest encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database,\nincluding metadata fields and checkpoint blobs.",
"type": "object",
"properties": {
"path": {
@@ -759,13 +781,10 @@
"type": "null"
}
],
"description": "Headers to include (if not also matches against an 'exludes' pattern.\n"
"description": "Headers to include (if not also matched against an 'excludes' pattern).\n"
}
},
"required": [
"excludes",
"includes"
]
"required": []
},
"CorsConfig": {
"title": "CorsConfig",
@@ -908,6 +927,63 @@
}
},
"required": []
},
"WebhooksConfig": {
"title": "WebhooksConfig",
"type": "object",
"properties": {
"env_prefix": {
"type": "string",
"description": "Required prefix for environment variables referenced in header templates.\n\nActs as an allowlist boundary to prevent leaking arbitrary environment\nvariables. Defaults to \"LG_WEBHOOK_\" when omitted.\n"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "Static headers to include with webhook requests.\n\nValues may contain templates of the form \"${{ env.VAR }}\". On startup, these\nare resolved via the process environment after verifying `VAR` starts with\n`env_prefix`. Mixed literals and multiple templates are allowed.\n"
},
"url": {
"$ref": "#/$defs/WebhookUrlPolicy",
"description": "URL validation policy for user-supplied webhook endpoints."
}
},
"required": [],
"description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n d = {}\n for k, v in iterable:\n d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n in the keyword argument list. For example: dict(one=1, two=2)"
},
"WebhookUrlPolicy": {
"title": "WebhookUrlPolicy",
"type": "object",
"properties": {
"allowed_domains": {
"type": "array",
"items": {
"type": "string"
},
"description": "Hostname allowlist. Supports exact hosts and wildcard subdomains.\n\nUse entries like \"hooks.example.com\" or \"*.mycorp.com\". The wildcard only\nmatches subdomains (\"foo.mycorp.com\"), not the apex (\"mycorp.com\"). When\nempty or omitted, any public host is allowed (subject to SSRF IP checks).\n"
},
"allowed_ports": {
"type": "array",
"items": {
"type": "integer"
},
"description": "Explicit port allowlist for absolute URLs.\n\nIf set, requests must use one of these ports. Defaults are respected when\na port is not present in the URL (443 for https, 80 for http).\n"
},
"disable_loopback": {
"type": "boolean",
"description": "Disallow relative URLs (internal loopback calls) when true."
},
"max_url_length": {
"type": "integer",
"description": "Maximum permitted URL length in characters; longer inputs are rejected early."
},
"require_https": {
"type": "boolean",
"description": "Enforce HTTPS scheme for absolute URLs; reject `http://` when true."
}
},
"required": [],
"description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n d = {}\n for k, v in iterable:\n d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n in the keyword argument list. For example: dict(one=1, two=2)"
}
},
"title": "LangGraph CLI Configuration",
+82 -6
View File
@@ -210,6 +210,17 @@
}
],
"description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n"
},
"webhooks": {
"anyOf": [
{
"$ref": "#/$defs/WebhooksConfig"
},
{
"type": "null"
}
],
"description": "Optional. Webhooks configuration for outbound event delivery.\n\nForwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig`\nfor URL policy and header templating details.\n"
}
},
"required": [
@@ -413,6 +424,17 @@
}
],
"description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n"
},
"webhooks": {
"anyOf": [
{
"$ref": "#/$defs/WebhooksConfig"
},
{
"type": "null"
}
],
"description": "Optional. Webhooks configuration for outbound event delivery.\n\nForwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig`\nfor URL policy and header templating details.\n"
}
},
"required": [
@@ -616,7 +638,7 @@
},
"EncryptionConfig": {
"title": "EncryptionConfig",
"description": "Configuration for custom at-rest encryption logic.\n\n Allows you to implement custom encryption for sensitive data stored in the database,\n including metadata fields and checkpoint blobs.",
"description": "Configuration for custom at-rest encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database,\nincluding metadata fields and checkpoint blobs.",
"type": "object",
"properties": {
"path": {
@@ -759,13 +781,10 @@
"type": "null"
}
],
"description": "Headers to include (if not also matches against an 'exludes' pattern.\n"
"description": "Headers to include (if not also matched against an 'excludes' pattern).\n"
}
},
"required": [
"excludes",
"includes"
]
"required": []
},
"CorsConfig": {
"title": "CorsConfig",
@@ -908,6 +927,63 @@
}
},
"required": []
},
"WebhooksConfig": {
"title": "WebhooksConfig",
"type": "object",
"properties": {
"env_prefix": {
"type": "string",
"description": "Required prefix for environment variables referenced in header templates.\n\nActs as an allowlist boundary to prevent leaking arbitrary environment\nvariables. Defaults to \"LG_WEBHOOK_\" when omitted.\n"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "Static headers to include with webhook requests.\n\nValues may contain templates of the form \"${{ env.VAR }}\". On startup, these\nare resolved via the process environment after verifying `VAR` starts with\n`env_prefix`. Mixed literals and multiple templates are allowed.\n"
},
"url": {
"$ref": "#/$defs/WebhookUrlPolicy",
"description": "URL validation policy for user-supplied webhook endpoints."
}
},
"required": [],
"description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n d = {}\n for k, v in iterable:\n d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n in the keyword argument list. For example: dict(one=1, two=2)"
},
"WebhookUrlPolicy": {
"title": "WebhookUrlPolicy",
"type": "object",
"properties": {
"allowed_domains": {
"type": "array",
"items": {
"type": "string"
},
"description": "Hostname allowlist. Supports exact hosts and wildcard subdomains.\n\nUse entries like \"hooks.example.com\" or \"*.mycorp.com\". The wildcard only\nmatches subdomains (\"foo.mycorp.com\"), not the apex (\"mycorp.com\"). When\nempty or omitted, any public host is allowed (subject to SSRF IP checks).\n"
},
"allowed_ports": {
"type": "array",
"items": {
"type": "integer"
},
"description": "Explicit port allowlist for absolute URLs.\n\nIf set, requests must use one of these ports. Defaults are respected when\na port is not present in the URL (443 for https, 80 for http).\n"
},
"disable_loopback": {
"type": "boolean",
"description": "Disallow relative URLs (internal loopback calls) when true."
},
"max_url_length": {
"type": "integer",
"description": "Maximum permitted URL length in characters; longer inputs are rejected early."
},
"require_https": {
"type": "boolean",
"description": "Enforce HTTPS scheme for absolute URLs; reject `http://` when true."
}
},
"required": [],
"description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n d = {}\n for k, v in iterable:\n d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n in the keyword argument list. For example: dict(one=1, two=2)"
}
},
"title": "LangGraph CLI Configuration",
+85 -1
View File
@@ -49,6 +49,7 @@ def test_validate_config():
"store": None,
"auth": None,
"encryption": None,
"webhooks": None,
"checkpointer": None,
"http": None,
"ui": None,
@@ -76,6 +77,7 @@ def test_validate_config():
"store": None,
"auth": None,
"encryption": None,
"webhooks": None,
"checkpointer": None,
"http": None,
"ui": None,
@@ -798,7 +800,10 @@ def test_config_to_docker_python_encryption_formatted():
)
# Verify that LANGGRAPH_ENCRYPTION is in the docker output with the correct path
assert "LANGGRAPH_ENCRYPTION=" in actual_docker_stdin
assert "/deps/outer-unit_tests/unit_tests/agent.py:my_encryption" in actual_docker_stdin
assert (
"/deps/outer-unit_tests/unit_tests/agent.py:my_encryption"
in actual_docker_stdin
)
def test_config_to_docker_nodejs_internal_docker_tag():
@@ -834,6 +839,85 @@ RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not foun
assert additional_contexts == {}
def _extract_env_json(dockerfile: str, var_name: str) -> dict:
"""Helper to extract and parse a JSON value from an ENV line in a Dockerfile."""
line_prefix = f"ENV {var_name}='"
for line in dockerfile.splitlines():
if line.startswith(line_prefix) and line.endswith("'"):
json_str = line[len(line_prefix) : -1]
return json.loads(json_str)
raise AssertionError(f"{var_name} not found in Dockerfile env lines")
def test_config_to_docker_webhooks_python():
graphs = {"agent": "./agent.py:graph"}
webhooks = {
"env_prefix": "LG_WEBHOOK_",
"url": {
"require_https": True,
"allowed_domains": ["hooks.example.com", "*.example.org"],
"allowed_ports": [443],
"max_url_length": 1024,
"disable_loopback": False,
},
"headers": {
"x-auth": "${{ env.LG_WEBHOOK_TOKEN }}",
"x-mixed": "Bearer ${{ env.LG_WEBHOOK_TOKEN }}-suffix",
},
}
dockerfile, _ = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{
"dependencies": ["."],
"graphs": graphs,
"webhooks": webhooks,
}
),
"langchain/langgraph-api",
)
# Ensure the ENV line is present and the payload round-trips via JSON
parsed = _extract_env_json(dockerfile, "LANGGRAPH_WEBHOOKS")
assert parsed == webhooks
def test_config_to_docker_webhooks_node():
graphs = {"agent": "./graphs/agent.js:graph"}
webhooks = {
"env_prefix": "LG_WEBHOOK_",
"url": {"require_https": True},
"headers": {"x-auth": "${{ env.LG_WEBHOOK_TOKEN }}"},
}
dockerfile, _ = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{
"node_version": "20",
"graphs": graphs,
"webhooks": webhooks,
}
),
"langchain/langgraphjs-api",
)
parsed = _extract_env_json(dockerfile, "LANGGRAPH_WEBHOOKS")
assert parsed == webhooks
def test_config_to_docker_no_webhooks():
graphs = {"agent": "./agent.py:graph"}
dockerfile, _ = config_to_docker(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
)
assert "ENV LANGGRAPH_WEBHOOKS=" not in dockerfile
def test_config_to_docker_gen_ui_python():
graphs = {"agent": "./agent.py:graph"}
actual_docker_stdin, additional_contexts = config_to_docker(
+978 -411
View File
File diff suppressed because it is too large Load Diff
+76 -75
View File
@@ -195,7 +195,7 @@ name = "blockbuster"
version = "1.5.26"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "forbiddenfruit", marker = "python_full_version >= '3.11' and implementation_name == 'cpython'" },
{ name = "forbiddenfruit", marker = "python_full_version >= '3.11' and python_full_version < '3.14' and implementation_name == 'cpython'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e0/dcbab602790a576b0b94108c07e2c048e5897df7cc83722a89582d733987/blockbuster-1.5.26.tar.gz", hash = "sha256:cc3ce8c70fa852a97ee3411155f31e4ad2665cd1c6c7d2f8bb1851dab61dc629", size = 36085, upload-time = "2025-12-05T10:43:47.735Z" }
wheels = [
@@ -387,7 +387,7 @@ name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "colorama", marker = "python_full_version < '3.14' and sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
@@ -530,7 +530,7 @@ name = "cryptography"
version = "44.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "python_full_version >= '3.11' and platform_python_implementation != 'PyPy'" },
{ name = "cffi", marker = "python_full_version >= '3.11' and python_full_version < '3.14' and platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/d6/1411ab4d6108ab167d06254c5be517681f1e331f90edf1379895bcb87020/cryptography-44.0.3.tar.gz", hash = "sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053", size = 711096, upload-time = "2025-05-02T19:36:04.667Z" }
wheels = [
@@ -678,7 +678,7 @@ name = "googleapis-common-protos"
version = "1.72.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf", marker = "python_full_version >= '3.11'" },
{ name = "protobuf", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" }
wheels = [
@@ -690,7 +690,7 @@ name = "grpcio"
version = "1.76.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version >= '3.11'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" }
wheels = [
@@ -751,9 +751,9 @@ name = "grpcio-tools"
version = "1.75.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "grpcio", marker = "python_full_version >= '3.11'" },
{ name = "protobuf", marker = "python_full_version >= '3.11'" },
{ name = "setuptools", marker = "python_full_version >= '3.11'" },
{ name = "grpcio", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "protobuf", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "setuptools", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/76/0cd2a2bb379275c319544a3ab613dc3cea7a167503908c1b4de55f82bd9e/grpcio_tools-1.75.1.tar.gz", hash = "sha256:bb78960cf3d58941e1fec70cbdaccf255918beed13c34112a6915a6d8facebd1", size = 5390470, upload-time = "2025-09-26T09:10:11.948Z" }
wheels = [
@@ -860,7 +860,7 @@ name = "importlib-metadata"
version = "8.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp", marker = "python_full_version >= '3.11'" },
{ name = "zipp", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" }
wheels = [
@@ -1488,39 +1488,39 @@ test = [
[[package]]
name = "langgraph-api"
version = "0.5.30"
version = "0.5.35"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cloudpickle", marker = "python_full_version >= '3.11'" },
{ name = "cryptography", marker = "python_full_version >= '3.11'" },
{ name = "grpcio", marker = "python_full_version >= '3.11'" },
{ name = "grpcio-tools", marker = "python_full_version >= '3.11'" },
{ name = "httpx", marker = "python_full_version >= '3.11'" },
{ name = "jsonschema-rs", marker = "python_full_version >= '3.11'" },
{ name = "langchain-core", marker = "python_full_version >= '3.11'" },
{ name = "langgraph", marker = "python_full_version >= '3.11'" },
{ name = "langgraph-checkpoint", marker = "python_full_version >= '3.11'" },
{ name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11'" },
{ name = "langgraph-sdk", marker = "python_full_version >= '3.11'" },
{ name = "langsmith", marker = "python_full_version >= '3.11'" },
{ name = "opentelemetry-api", marker = "python_full_version >= '3.11'" },
{ name = "opentelemetry-exporter-otlp-proto-http", marker = "python_full_version >= '3.11'" },
{ name = "opentelemetry-sdk", marker = "python_full_version >= '3.11'" },
{ name = "orjson", marker = "python_full_version >= '3.11'" },
{ name = "protobuf", marker = "python_full_version >= '3.11'" },
{ name = "pyjwt", marker = "python_full_version >= '3.11'" },
{ name = "sse-starlette", marker = "python_full_version >= '3.11'" },
{ name = "starlette", marker = "python_full_version >= '3.11'" },
{ name = "structlog", marker = "python_full_version >= '3.11'" },
{ name = "tenacity", marker = "python_full_version >= '3.11'" },
{ name = "truststore", marker = "python_full_version >= '3.11'" },
{ name = "uuid-utils", marker = "python_full_version >= '3.11'" },
{ name = "uvicorn", marker = "python_full_version >= '3.11'" },
{ name = "watchfiles", marker = "python_full_version >= '3.11'" },
{ name = "cloudpickle", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "cryptography", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "grpcio", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "grpcio-tools", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "httpx", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "jsonschema-rs", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "langchain-core", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "langgraph", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "langgraph-checkpoint", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "langgraph-sdk", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "langsmith", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "opentelemetry-api", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "opentelemetry-exporter-otlp-proto-http", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "opentelemetry-sdk", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "orjson", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "protobuf", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "pyjwt", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "sse-starlette", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "starlette", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "structlog", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "tenacity", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "truststore", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "uuid-utils", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "uvicorn", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "watchfiles", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/fb/0f75ac52d7aa9bf9c001b7d36e1515a849904a9c7acc51f4ca5b8bd873f4/langgraph_api-0.5.30.tar.gz", hash = "sha256:f95f9102ca9b8a1716be7c57d7812344c785c9a1785b3bda84f1c30517c5fbec", size = 367716, upload-time = "2025-12-05T04:04:08.557Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/27/4dd4287ec65690e3a212d7154e20504b4e88e861fd62625053be8903bc57/langgraph_api-0.5.35.tar.gz", hash = "sha256:b5687a5201ff365e1bc016042a7103ed8a2c2440f57b71f8480c223585bbfca1", size = 378029, upload-time = "2025-12-09T00:37:35.091Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/b3/9ca17fa9417d885ee9f5d22e4629029ae848282fcf6dac9cdbe5e0b0ec71/langgraph_api-0.5.30-py3-none-any.whl", hash = "sha256:aa2d9fedc3d1c9394bd1534265f107bcb508e31e4f029fad1795489178d1adf2", size = 295086, upload-time = "2025-12-05T04:04:07.032Z" },
{ url = "https://files.pythonhosted.org/packages/5a/80/296db2db262a90b0fe3cb2562790025e018e33da9d171cc64f12076e5911/langgraph_api-0.5.35-py3-none-any.whl", hash = "sha256:6aaf967c52ff719861b80e4dc8066968baa185d9daae8433f0d84b5a27708a65", size = 305523, upload-time = "2025-12-09T00:37:34.001Z" },
]
[[package]]
@@ -1572,7 +1572,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-postgres"
version = "3.0.1"
version = "3.0.2"
source = { editable = "../checkpoint-postgres" }
dependencies = [
{ name = "langgraph-checkpoint" },
@@ -1619,7 +1619,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-sqlite"
version = "3.0.0"
version = "3.0.1"
source = { editable = "../checkpoint-sqlite" }
dependencies = [
{ name = "aiosqlite" },
@@ -1664,21 +1664,21 @@ test = [
name = "langgraph-cli"
source = { editable = "../cli" }
dependencies = [
{ name = "click" },
{ name = "langgraph-sdk", marker = "python_full_version >= '3.11'" },
{ name = "click", marker = "python_full_version < '3.14'" },
{ name = "langgraph-sdk", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
]
[package.optional-dependencies]
inmem = [
{ name = "langgraph-api", marker = "python_full_version >= '3.11'" },
{ name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11'" },
{ name = "python-dotenv" },
{ name = "langgraph-api", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "python-dotenv", marker = "python_full_version < '3.14'" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.1.7" },
{ name = "langgraph-api", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.4,<0.6.0" },
{ name = "langgraph-api", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.5.35,<0.6.0" },
{ name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.7" },
{ name = "langgraph-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.1.0" },
{ name = "python-dotenv", marker = "extra == 'inmem'", specifier = ">=0.8.0" },
@@ -1688,6 +1688,7 @@ provides-extras = ["inmem"]
[package.metadata.requires-dev]
dev = [
{ name = "codespell" },
{ name = "hatch", specifier = ">=1.16.2" },
{ name = "msgspec" },
{ name = "mypy" },
{ name = "pytest" },
@@ -1765,12 +1766,12 @@ name = "langgraph-runtime-inmem"
version = "0.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blockbuster", marker = "python_full_version >= '3.11'" },
{ name = "langgraph", marker = "python_full_version >= '3.11'" },
{ name = "langgraph-checkpoint", marker = "python_full_version >= '3.11'" },
{ name = "sse-starlette", marker = "python_full_version >= '3.11'" },
{ name = "starlette", marker = "python_full_version >= '3.11'" },
{ name = "structlog", marker = "python_full_version >= '3.11'" },
{ name = "blockbuster", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "langgraph", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "langgraph-checkpoint", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "sse-starlette", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "starlette", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "structlog", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/9e/6e7b321ef02834059983d6d5a635cc20f9987b19fe6a4666332c8b9b0ede/langgraph_runtime_inmem-0.19.1.tar.gz", hash = "sha256:573d576cf38392fcace76d772be9adc4d54b2af129ae54cb9780bab4fb55ee69", size = 98975, upload-time = "2025-12-04T07:01:40.105Z" }
wheels = [
@@ -2180,8 +2181,8 @@ name = "opentelemetry-api"
version = "1.39.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata", marker = "python_full_version >= '3.11'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.11'" },
{ name = "importlib-metadata", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/0b/e5428c009d4d9af0515b0a8371a8aaae695371af291f45e702f7969dce6b/opentelemetry_api-1.39.0.tar.gz", hash = "sha256:6130644268c5ac6bdffaf660ce878f10906b3e789f7e2daa5e169b047a2933b9", size = 65763, upload-time = "2025-12-03T13:19:56.378Z" }
wheels = [
@@ -2193,7 +2194,7 @@ name = "opentelemetry-exporter-otlp-proto-common"
version = "1.39.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-proto", marker = "python_full_version >= '3.11'" },
{ name = "opentelemetry-proto", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/11/cb/3a29ce606b10c76d413d6edd42d25a654af03e73e50696611e757d2602f3/opentelemetry_exporter_otlp_proto_common-1.39.0.tar.gz", hash = "sha256:a135fceed1a6d767f75be65bd2845da344dd8b9258eeed6bc48509d02b184409", size = 20407, upload-time = "2025-12-03T13:19:59.003Z" }
wheels = [
@@ -2205,13 +2206,13 @@ name = "opentelemetry-exporter-otlp-proto-http"
version = "1.39.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos", marker = "python_full_version >= '3.11'" },
{ name = "opentelemetry-api", marker = "python_full_version >= '3.11'" },
{ name = "opentelemetry-exporter-otlp-proto-common", marker = "python_full_version >= '3.11'" },
{ name = "opentelemetry-proto", marker = "python_full_version >= '3.11'" },
{ name = "opentelemetry-sdk", marker = "python_full_version >= '3.11'" },
{ name = "requests", marker = "python_full_version >= '3.11'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.11'" },
{ name = "googleapis-common-protos", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "opentelemetry-api", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "opentelemetry-exporter-otlp-proto-common", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "opentelemetry-proto", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "opentelemetry-sdk", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "requests", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/dc/1e9bf3f6a28e29eba516bc0266e052996d02bc7e92675f3cd38169607609/opentelemetry_exporter_otlp_proto_http-1.39.0.tar.gz", hash = "sha256:28d78fc0eb82d5a71ae552263d5012fa3ebad18dfd189bf8d8095ba0e65ee1ed", size = 17287, upload-time = "2025-12-03T13:20:01.134Z" }
wheels = [
@@ -2223,7 +2224,7 @@ name = "opentelemetry-proto"
version = "1.39.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf", marker = "python_full_version >= '3.11'" },
{ name = "protobuf", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/b5/64d2f8c3393cd13ea2092106118f7b98461ba09333d40179a31444c6f176/opentelemetry_proto-1.39.0.tar.gz", hash = "sha256:c1fa48678ad1a1624258698e59be73f990b7fc1f39e73e16a9d08eef65dd838c", size = 46153, upload-time = "2025-12-03T13:20:08.729Z" }
wheels = [
@@ -2235,9 +2236,9 @@ name = "opentelemetry-sdk"
version = "1.39.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api", marker = "python_full_version >= '3.11'" },
{ name = "opentelemetry-semantic-conventions", marker = "python_full_version >= '3.11'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.11'" },
{ name = "opentelemetry-api", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "opentelemetry-semantic-conventions", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/51/e3/7cd989003e7cde72e0becfe830abff0df55c69d237ee7961a541e0167833/opentelemetry_sdk-1.39.0.tar.gz", hash = "sha256:c22204f12a0529e07aa4d985f1bca9d6b0e7b29fe7f03e923548ae52e0e15dde", size = 171322, upload-time = "2025-12-03T13:20:09.651Z" }
wheels = [
@@ -2249,8 +2250,8 @@ name = "opentelemetry-semantic-conventions"
version = "0.60b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api", marker = "python_full_version >= '3.11'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.11'" },
{ name = "opentelemetry-api", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/0e/176a7844fe4e3cb5de604212094dffaed4e18b32f1c56b5258bcbcba85c2/opentelemetry_semantic_conventions-0.60b0.tar.gz", hash = "sha256:227d7aa73cbb8a2e418029d6b6465553aa01cf7e78ec9d0bc3255c7b3ac5bf8f", size = 137935, upload-time = "2025-12-03T13:20:12.395Z" }
wheels = [
@@ -3431,9 +3432,9 @@ name = "sse-starlette"
version = "2.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "python_full_version >= '3.11'" },
{ name = "starlette", marker = "python_full_version >= '3.11'" },
{ name = "uvicorn", marker = "python_full_version >= '3.11'" },
{ name = "anyio", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "starlette", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "uvicorn", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/fc/56ab9f116b2133521f532fce8d03194cf04dcac25f583cf3d839be4c0496/sse_starlette-2.1.3.tar.gz", hash = "sha256:9cd27eb35319e1414e3d2558ee7414487f9529ce3b3cf9b21434fd110e017169", size = 19678, upload-time = "2024-08-01T08:52:50.248Z" }
wheels = [
@@ -3459,7 +3460,7 @@ name = "starlette"
version = "0.50.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "python_full_version >= '3.11'" },
{ name = "anyio", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
@@ -3703,8 +3704,8 @@ name = "uvicorn"
version = "0.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click", marker = "python_full_version >= '3.11'" },
{ name = "h11", marker = "python_full_version >= '3.11'" },
{ name = "click", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
{ name = "h11", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
wheels = [
@@ -3780,7 +3781,7 @@ name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "python_full_version >= '3.11'" },
{ name = "anyio", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
+2 -2
View File
@@ -402,7 +402,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-postgres"
version = "3.0.1"
version = "3.0.2"
source = { editable = "../checkpoint-postgres" }
dependencies = [
{ name = "langgraph-checkpoint" },
@@ -449,7 +449,7 @@ test = [
[[package]]
name = "langgraph-checkpoint-sqlite"
version = "3.0.0"
version = "3.0.1"
source = { editable = "../checkpoint-sqlite" }
dependencies = [
{ name = "aiosqlite" },
+1 -1
View File
@@ -3,6 +3,6 @@ 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.2.14"
__version__ = "0.2.15"
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]
+10
View File
@@ -1193,6 +1193,7 @@ class AssistantsClient:
*,
metadata: Json = None,
graph_id: str | None = None,
name: str | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> int:
@@ -1201,6 +1202,8 @@ class AssistantsClient:
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.
@@ -1212,6 +1215,8 @@ class AssistantsClient:
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
)
@@ -4526,6 +4531,7 @@ class SyncAssistantsClient:
*,
metadata: Json = None,
graph_id: str | None = None,
name: str | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> int:
@@ -4534,6 +4540,8 @@ class SyncAssistantsClient:
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.
@@ -4545,6 +4553,8 @@ class SyncAssistantsClient:
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
)
-61
View File
@@ -1,61 +0,0 @@
# Security Policy
## Reporting OSS Vulnerabilities
LangChain is partnered with [huntr by Protect AI](https://huntr.com/) to provide
a bounty program for our open source projects.
Please report security vulnerabilities associated with the LangChain
open source projects by visiting the following link:
[https://huntr.com/bounties/disclose/](https://huntr.com/bounties/disclose/?target=https%3A%2F%2Fgithub.com%2Flangchain-ai%2Flangchain&validSearch=true)
Before reporting a vulnerability, please review:
1) In-Scope Targets and Out-of-Scope Targets below.
2) The [langchain-ai/langchain](https://github.com/langchain-ai/langchain) monorepo structure.
3) LangChain [security guidelines](https://python.langchain.com/docs/security) to
understand what we consider to be a security vulnerability vs. developer
responsibility.
### In-Scope Targets
The following packages and repositories are eligible for bug bounties:
- langchain-core
- langchain (see exceptions)
- langchain-community (see exceptions)
- langgraph
- langserve
### Out of Scope Targets
All out of scope targets defined by huntr as well as:
- **langchain-experimental**: This repository is for experimental code and is not
eligible for bug bounties, bug reports to it will be marked as interesting or waste of
time and published with no bounty attached.
- **tools**: Tools in either langchain or langchain-community are not eligible for bug
bounties. This includes the following directories
- langchain/tools
- langchain-community/tools
- Please review our [security guidelines](https://python.langchain.com/docs/security)
for more details, but generally tools interact with the real world. Developers are
expected to understand the security implications of their code and are responsible
for the security of their tools.
- Code documented with security notices. This will be decided done on a case by
case basis, but likely will not be eligible for a bounty as the code is already
documented with guidelines for developers that should be followed for making their
application secure.
- Any LangSmith related repositories or APIs see below.
## Reporting LangSmith Vulnerabilities
Please report security vulnerabilities associated with LangSmith by email to `security@langchain.dev`.
- LangSmith site: <https://smith.langchain.com>
- SDK client: <https://github.com/langchain-ai/langsmith-sdk>
### Other Security Concerns
For any other security concerns, please contact us at `security@langchain.dev`.