chore: import upstream snapshot with attribution
CI / changes (push) Has been cancelled
CI / cd libs/checkpoint (push) Has been cancelled
CI / cd libs/checkpoint-conformance (push) Has been cancelled
CI / cd libs/checkpoint-postgres (push) Has been cancelled
CI / cd libs/checkpoint-sqlite (push) Has been cancelled
CI / cd libs/cli (push) Has been cancelled
CI / cd libs/prebuilt (push) Has been cancelled
CI / cd libs/sdk-py (push) Has been cancelled
CI / cd libs/langgraph (push) Has been cancelled
CI / Check SDK methods matching (push) Has been cancelled
CI / Check CLI schema hasn't changed #3.13 (push) Has been cancelled
CI / CLI integration test (push) Has been cancelled
CI / sdk-py integration test (push) Has been cancelled
CI / CI Success (push) Has been cancelled
baseline / benchmark (push) Has been cancelled
Deploy Redirects to GitHub Pages / deploy (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:18 +08:00
commit a7d6d88f6f
667 changed files with 232986 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
__version__ = "0.4.31"
+4
View File
@@ -0,0 +1,4 @@
from .cli import cli
if __name__ == "__main__":
cli()
+124
View File
@@ -0,0 +1,124 @@
"""Shared ignore-file handling for local source filtering."""
import pathlib
from dataclasses import dataclass
import pathspec
_ALWAYS_EXCLUDE = [
"__pycache__/",
".git/",
".venv/",
"venv/",
"node_modules/",
".tox/",
".mypy_cache/",
]
_ALWAYS_EXCLUDE_NAMES = frozenset(
pattern.rstrip("/").split("/")[-1] for pattern in _ALWAYS_EXCLUDE
)
_GLOB_CHARS = frozenset("*?[")
@dataclass(frozen=True, slots=True)
class _NegatedDockerignoreHints:
exact_dirs: frozenset[pathlib.PurePosixPath] = frozenset()
wildcard_prefixes: frozenset[pathlib.PurePosixPath] = frozenset()
recurse_all: bool = False
def requires_dir_walk(self, path: pathlib.PurePosixPath) -> bool:
if self.recurse_all or path in self.exact_dirs:
return True
return any(
path == prefix or path in prefix.parents or prefix in path.parents
for prefix in self.wildcard_prefixes
)
def _build_ignore_spec(
directory: pathlib.Path, *, include_gitignore: bool = True
) -> pathspec.PathSpec:
"""Build a PathSpec combining built-in exclusions with ignore files.
Always excludes common non-source directories (`_ALWAYS_EXCLUDE`). On top
of that, patterns from `.dockerignore` are merged in. `.gitignore` patterns
are optional because some callers need Docker build-context semantics,
while archive creation wants both files.
"""
lines: list[str] = list(_ALWAYS_EXCLUDE)
ignore_files = [".dockerignore"]
if include_gitignore:
ignore_files.append(".gitignore")
for name in ignore_files:
ignore_file = directory / name
if ignore_file.is_file():
lines.extend(ignore_file.read_text(encoding="utf-8").splitlines())
return pathspec.PathSpec.from_lines("gitwildmatch", lines)
def _is_always_excluded(path: pathlib.PurePosixPath, *, is_dir: bool) -> bool:
"""Whether `path` lives inside a built-in excluded directory."""
parent_parts = path.parts if is_dir else path.parts[:-1]
return any(part in _ALWAYS_EXCLUDE_NAMES for part in parent_parts)
def _build_dockerignore_negation_hints(
directory: pathlib.Path,
) -> _NegatedDockerignoreHints:
"""Summarize which ignored directories must still be traversed.
Most negations only require walking a small, concrete chain of parent
directories (for example `!assets/keep.txt` requires entering `assets/`).
Broader glob negations may force a wider walk.
"""
ignore_file = directory / ".dockerignore"
if not ignore_file.is_file():
return _NegatedDockerignoreHints()
exact_dirs: set[pathlib.PurePosixPath] = set()
wildcard_prefixes: set[pathlib.PurePosixPath] = set()
recurse_all = False
for raw_line in ignore_file.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or line.startswith("\\!"):
continue
if line.startswith("\\#"):
line = line[1:]
if not line.startswith("!"):
continue
pattern = line[1:].lstrip("/")
while pattern.startswith("./"):
pattern = pattern[2:]
pattern = pattern.rstrip("/")
parts = [part for part in pattern.split("/") if part and part != "."]
if not parts:
recurse_all = True
continue
wildcard_index = next(
(
idx
for idx, part in enumerate(parts)
if any(char in part for char in _GLOB_CHARS)
),
None,
)
if wildcard_index is not None:
literal_parts = parts[:wildcard_index]
if not literal_parts:
recurse_all = True
continue
wildcard_prefixes.add(pathlib.PurePosixPath(*literal_parts))
continue
parent_parts = parts[:-1]
for idx in range(1, len(parent_parts) + 1):
exact_dirs.add(pathlib.PurePosixPath(*parent_parts[:idx]))
return _NegatedDockerignoreHints(
exact_dirs=frozenset(exact_dirs),
wildcard_prefixes=frozenset(wildcard_prefixes),
recurse_all=recurse_all,
)
+105
View File
@@ -0,0 +1,105 @@
import functools
import json
import os
import pathlib
import platform
import threading
import urllib.error
import urllib.request
from typing import Any, TypedDict
from langgraph_cli.constants import (
DEFAULT_CONFIG,
DEFAULT_PORT,
SUPABASE_PUBLIC_API_KEY,
SUPABASE_URL,
)
from langgraph_cli.version import __version__
class LogData(TypedDict):
os: str
os_version: str
python_version: str
cli_version: str
cli_command: str
params: dict[str, Any]
def get_anonymized_params(
kwargs: dict[str, Any], *, cli_command: str
) -> dict[str, bool | str]:
params: dict[str, bool | str] = {}
if cli_command == "deploy" and (
analytics_source := os.getenv("LANGGRAPH_CLI_ANALYTICS_SOURCE")
):
params["source"] = analytics_source
# anonymize params with values
if config := kwargs.get("config"):
if config != pathlib.Path(DEFAULT_CONFIG).resolve():
params["config"] = True
if port := kwargs.get("port"):
if port != DEFAULT_PORT:
params["port"] = True
if kwargs.get("docker_compose"):
params["docker_compose"] = True
if kwargs.get("debugger_port"):
params["debugger_port"] = True
if kwargs.get("postgres_uri"):
params["postgres_uri"] = True
# pick up exact values for boolean flags
for boolean_param in ["recreate", "pull", "watch", "wait", "verbose"]:
if kwargs.get(boolean_param):
params[boolean_param] = kwargs[boolean_param]
return params
def log_data(data: LogData) -> None:
headers = {
"Content-Type": "application/json",
"apikey": SUPABASE_PUBLIC_API_KEY,
"User-Agent": "Mozilla/5.0",
}
supabase_url = SUPABASE_URL
req = urllib.request.Request(
f"{supabase_url}/rest/v1/logs",
data=json.dumps(data).encode("utf-8"),
headers=headers,
method="POST",
)
try:
urllib.request.urlopen(req)
except urllib.error.URLError:
pass
def log_command(func):
@functools.wraps(func)
def decorator(*args, **kwargs):
if os.getenv("LANGGRAPH_CLI_NO_ANALYTICS") == "1":
return func(*args, **kwargs)
data = {
"os": platform.system(),
"os_version": platform.version(),
"python_version": platform.python_version(),
"cli_version": __version__,
"cli_command": func.__name__,
"params": get_anonymized_params(kwargs, cli_command=func.__name__),
}
background_thread = threading.Thread(target=log_data, args=(data,))
background_thread.start()
return func(*args, **kwargs)
return decorator
+138
View File
@@ -0,0 +1,138 @@
"""Create a tarball of project source for remote builds."""
import os
import pathlib
import tarfile
import tempfile
from contextlib import contextmanager
import click
import pathspec
from langgraph_cli._ignore import _build_ignore_spec
from langgraph_cli.config import Config, _assemble_local_deps
_WARN_SIZE = 50 * 1024 * 1024 # 50 MB
_MAX_SIZE = 200 * 1024 * 1024 # 200 MB
def _tar_filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None:
"""Strip symlinks, hardlinks, and traversal paths from archive."""
if tarinfo.issym() or tarinfo.islnk():
return None
if ".." in tarinfo.name.split("/"):
return None
return tarinfo
def _add_directory(
tar: tarfile.TarFile,
source_dir: pathlib.Path,
arcname_prefix: str | None,
ignore_spec: pathspec.PathSpec,
) -> None:
"""Recursively add a directory to the tarball under the given prefix.
If arcname_prefix is None, files are added at the archive root.
Paths matching ignore_spec are excluded.
"""
for root, dirs, files in os.walk(source_dir):
rel_root = os.path.relpath(root, source_dir).replace(os.sep, "/")
dirs[:] = [
d
for d in dirs
if not ignore_spec.match_file(
f"{rel_root}/{d}/" if rel_root != "." else f"{d}/"
)
]
for f in files:
full_path = os.path.join(root, f)
rel = os.path.relpath(full_path, source_dir).replace(os.sep, "/")
if ignore_spec.match_file(rel):
continue
arcname = f"{arcname_prefix}/{rel}" if arcname_prefix else rel
info = tar.gettarinfo(full_path, arcname=arcname)
filtered = _tar_filter(info)
if filtered is None:
continue
with open(full_path, "rb") as fobj:
tar.addfile(filtered, fobj)
@contextmanager
def create_archive(
config_path: pathlib.Path,
config: Config,
):
"""Context manager that creates a .tar.gz archive of the project source.
Uses _assemble_local_deps to discover local dependencies referenced in
langgraph.json, including those outside config.parent (monorepo case).
The archive preserves the real filesystem layout relative to the common
ancestor of config.parent and all external dependency directories, so that
relative references (e.g. `../shared-lib`) resolve correctly after
extraction.
Yields (archive_path, file_size, config_relative_path). The temporary
directory holding the archive is cleaned up automatically on exit.
"""
config_path = config_path.resolve()
context_dir = config_path.parent
local_deps = _assemble_local_deps(config_path, config)
extra_contexts = local_deps.additional_contexts or []
dirs_to_include = [context_dir] + list(extra_contexts)
common = context_dir
for d in extra_contexts:
common = pathlib.Path(os.path.commonpath([common, d]))
tmp_dir = tempfile.mkdtemp(prefix="langgraph-deploy-")
try:
archive_path = os.path.join(tmp_dir, "source.tar.gz")
added_dirs: set[str] = set()
with tarfile.open(archive_path, "w:gz") as tar:
for dir_path in dirs_to_include:
rel = dir_path.relative_to(common)
prefix = str(rel).replace(os.sep, "/") if str(rel) != "." else None
key = prefix or ""
if key in added_dirs:
continue
added_dirs.add(key)
ignore_spec = _build_ignore_spec(dir_path)
_add_directory(
tar, dir_path, arcname_prefix=prefix, ignore_spec=ignore_spec
)
file_size = os.path.getsize(archive_path)
config_rel = str(config_path.relative_to(common)).replace(os.sep, "/")
with tarfile.open(archive_path, "r:gz") as tar:
names = tar.getnames()
if config_rel not in names:
raise click.ClickException(
f"Archive validation failed: {config_rel} not found in archive"
)
if file_size > _MAX_SIZE:
raise click.ClickException(
f"Source archive is {file_size / 1_048_576:.1f} MB, which exceeds the 200 MB limit. "
"Add large files to .dockerignore or .gitignore (model weights, data sets, etc.)."
)
if file_size > _WARN_SIZE:
click.secho(
f" Warning: source archive is {file_size / 1_048_576:.1f} MB. "
"Consider adding large files to .dockerignore or .gitignore.",
fg="yellow",
)
yield archive_path, file_size, config_rel
finally:
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
DEFAULT_CONFIG = "langgraph.json"
DEFAULT_PORT = 8123
# analytics
SUPABASE_PUBLIC_API_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imt6cmxwcG9qaW5wY3l5YWlweG5iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTkyNTc1NzksImV4cCI6MjAzNDgzMzU3OX0.kkVOlLz3BxemA5nP-vat3K4qRtrDuO4SwZSR_htcX9c"
SUPABASE_URL = "https://kzrlppojinpcyyaipxnb.supabase.co"
@@ -0,0 +1,141 @@
"""Detection of tracked Python packages in a local LangGraph project.
Mirrors host-backend's `host.models.dependency_tracking` so that CLI-based
deploys report the same `tracked_packages` revision metadata that
GitHub-based deploys do. The host backend strictly validates each entry
against `<package-name>:<version>` with package-name in `TRACKED_PACKAGES`,
so the detection rules here must match exactly.
"""
from __future__ import annotations
import pathlib
import re
# Single source of truth for which packages the host backend cares about.
# Keep in sync with host-backend/host/models/tracked_packages.py.
TRACKED_PACKAGES: tuple[str, ...] = ("google-adk",)
_MAX_READ_BYTES = 5 * 1024 * 1024
_PACKAGES_ALT = "|".join(re.escape(p) for p in TRACKED_PACKAGES)
_DEPS_RE = re.compile(
rf"(?<![a-zA-Z0-9_-])({_PACKAGES_ALT})"
r"(?:\[[^\]]*\])?"
r"\s*((?:(?:==|>=|<=|~=|!=|>|<)\s*[\w.*]+\s*,?\s*)+)"
)
_UV_LOCK_RE = re.compile(
rf'name\s*=\s*"({_PACKAGES_ALT})"\s*\n\s*version\s*=\s*"([^"]+)"'
)
_BARE_RE = re.compile(rf'(?<![a-zA-Z0-9_-])({_PACKAGES_ALT})(?:\[[^\]]*\])?\s*[,"\'\n]')
_EXTRAS_BRACKET_RE = re.compile(r"\[([a-zA-Z0-9_.\- ,\t]+)\]")
def _appears_in_extras(content: str, pkg: str) -> bool:
for m in _EXTRAS_BRACKET_RE.finditer(content):
for token in m.group(1).split(","):
if token.strip() == pkg:
return True
return False
def _read_text(path: pathlib.Path) -> str | None:
try:
if not path.is_file():
return None
with open(path, "rb") as f:
data = f.read(_MAX_READ_BYTES + 1)
except OSError:
return None
if len(data) > _MAX_READ_BYTES:
data = data[:_MAX_READ_BYTES]
return data.decode("utf-8", errors="replace")
def _find_version_for(
pkg: str,
lock_content: str | None,
pyproject_content: str | None,
requirements_content: str | None,
) -> str | None:
if lock_content is not None:
for m in _UV_LOCK_RE.finditer(lock_content):
if m.group(1) == pkg:
return m.group(2)
for content in (pyproject_content, requirements_content):
if content is None:
continue
for m in _DEPS_RE.finditer(content):
if m.group(1) == pkg:
return m.group(2).strip().rstrip(",")
for m in _BARE_RE.finditer(content):
if m.group(1) == pkg:
return "unknown"
if _appears_in_extras(content, pkg):
return "unknown"
return None
def _resolved_dep_base(
project_root: pathlib.Path, dep_path: str
) -> pathlib.Path | None:
"""Return the resolved dep directory if it stays inside the project root."""
try:
candidate = (project_root / dep_path).resolve()
except (OSError, RuntimeError):
return None
try:
candidate.relative_to(project_root)
except ValueError:
return None
return candidate
def find_tracked_packages(
config: pathlib.Path,
config_json: dict,
) -> list[str]:
"""Return every tracked package found in deps as `<name>:<version>` entries.
`config` is the absolute path to `langgraph.json`; dep paths in
`config_json["dependencies"]` are resolved relative to its parent.
Detection precedence per package: uv.lock resolved > pyproject.toml /
requirements.txt specifier > bare reference > extras bracket (last
two recorded as "unknown"). Output is ordered by `TRACKED_PACKAGES`.
"""
try:
project_root = config.parent.resolve()
except (OSError, RuntimeError):
return []
dep_paths = config_json.get("dependencies") or ["."]
found: dict[str, str] = {}
for dep_path in dep_paths:
if all(pkg in found for pkg in TRACKED_PACKAGES):
break
if not isinstance(dep_path, str):
continue
base = _resolved_dep_base(project_root, dep_path)
if base is None or not base.is_dir():
continue
lock_content = _read_text(base / "uv.lock")
pyproject_content = _read_text(base / "pyproject.toml")
requirements_content = _read_text(base / "requirements.txt")
for pkg in TRACKED_PACKAGES:
if pkg in found:
continue
version = _find_version_for(
pkg, lock_content, pyproject_content, requirements_content
)
if version is not None:
found[pkg] = version
return [f"{pkg}:{found[pkg]}" for pkg in TRACKED_PACKAGES if pkg in found]
File diff suppressed because it is too large Load Diff
+406
View File
@@ -0,0 +1,406 @@
import copy
import json
import pathlib
import platform
import shutil
from collections.abc import Callable, Sequence
from typing import Literal, NamedTuple
import click.exceptions
import langgraph_cli.config
from langgraph_cli.exec import subp_exec
ROOT = pathlib.Path(__file__).parent.resolve()
DEFAULT_POSTGRES_URI = (
"postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
)
class Version(NamedTuple):
major: int
minor: int
patch: int
DockerComposeType = Literal["plugin", "standalone"]
class DockerCapabilities(NamedTuple):
version_docker: Version
version_compose: Version
healthcheck_start_interval: bool
compose_type: DockerComposeType = "plugin"
def _parse_version(version: str) -> Version:
parts = version.split(".", 2)
if len(parts) == 1:
major = parts[0]
minor = "0"
patch = "0"
elif len(parts) == 2:
major, minor = parts
patch = "0"
else:
major, minor, patch = parts
return Version(
int(major.lstrip("v")), int(minor), int(patch.split("-")[0].split("+")[0])
)
def can_build_locally() -> tuple[bool, str | None]:
"""Return whether local deployment builds can run on this machine.
Checks:
- Docker binary is installed
- Docker daemon is running
- Buildx is available when cross-compilation is required (non-x86_64)
"""
if shutil.which("docker") is None:
return (
False,
"Docker is required but not installed.\n"
"Install Docker Desktop: https://docs.docker.com/get-docker/",
)
try:
import subprocess
docker_info = subprocess.run(
["docker", "info"],
capture_output=True,
timeout=10,
)
if docker_info.returncode != 0:
return (
False,
"Docker is installed but not running.\nStart Docker and try again.",
)
if platform.machine() != "x86_64":
buildx = subprocess.run(
["docker", "buildx", "version"],
capture_output=True,
timeout=10,
)
if buildx.returncode != 0:
return (
False,
"Docker Buildx is required but not installed.\n"
"Your machine architecture ("
+ platform.machine()
+ ") requires Buildx to cross-compile images for linux/amd64.\n"
"Install Buildx: https://docs.docker.com/build/install-buildx/",
)
return True, None
except Exception:
return False, "Unable to verify local Docker build support."
def check_capabilities(runner) -> DockerCapabilities:
# check docker available
if shutil.which("docker") is None:
raise click.UsageError("Docker not installed") from None
try:
stdout, _ = runner.run(
subp_exec("docker", "info", "-f", "{{json .}}", collect=True)
)
info = json.loads(stdout)
except (click.exceptions.Exit, json.JSONDecodeError):
raise click.UsageError("Docker not installed or not running") from None
if not info["ServerVersion"]:
raise click.UsageError("Docker not running") from None
compose_type: DockerComposeType
try:
compose = next(
p for p in info["ClientInfo"]["Plugins"] if p["Name"] == "compose"
)
compose_version_str = compose["Version"]
compose_type = "plugin"
except (KeyError, StopIteration):
if shutil.which("docker-compose") is None:
raise click.UsageError("Docker Compose not installed") from None
compose_version_str, _ = runner.run(
subp_exec("docker-compose", "--version", "--short", collect=True)
)
compose_type = "standalone"
# parse versions
docker_version = _parse_version(info["ServerVersion"])
compose_version = _parse_version(compose_version_str)
# check capabilities
return DockerCapabilities(
version_docker=docker_version,
version_compose=compose_version,
healthcheck_start_interval=docker_version >= Version(25, 0, 0),
compose_type=compose_type,
)
def debugger_compose(*, port: int | None = None, base_url: str | None = None) -> dict:
if port is None:
return ""
config = {
"langgraph-debugger": {
"image": "langchain/langgraph-debugger",
"restart": "on-failure",
"depends_on": {
"langgraph-postgres": {"condition": "service_healthy"},
},
"ports": [f'"{port}:3968"'],
}
}
if base_url:
config["langgraph-debugger"]["environment"] = {
"VITE_STUDIO_LOCAL_GRAPH_URL": base_url
}
return config
# Function to convert dictionary to YAML
def dict_to_yaml(d: dict, *, indent: int = 0) -> str:
"""Convert a dictionary to a YAML string."""
yaml_str = ""
for idx, (key, value) in enumerate(d.items()):
# Format things in a visually appealing way
# Use an extra newline for top-level keys only
if idx >= 1 and indent < 2:
yaml_str += "\n"
space = " " * indent
if isinstance(value, dict):
yaml_str += f"{space}{key}:\n" + dict_to_yaml(value, indent=indent + 1)
elif isinstance(value, list):
yaml_str += f"{space}{key}:\n"
for item in value:
yaml_str += f"{space} - {item}\n"
else:
yaml_str += f"{space}{key}: {value}\n"
return yaml_str
def compose_as_dict(
capabilities: DockerCapabilities,
*,
port: int,
debugger_port: int | None = None,
debugger_base_url: str | None = None,
# postgres://user:password@host:port/database?option=value
postgres_uri: str | None = None,
# If you are running against an already-built image, you can pass it here
image: str | None = None,
# Base image to use for the LangGraph API server
base_image: str | None = None,
# API version of the base image
api_version: str | None = None,
engine_runtime_mode: str = "combined_queue_worker",
) -> dict:
"""Create a docker compose file as a dictionary in YML style."""
if postgres_uri is None:
include_db = True
postgres_uri = DEFAULT_POSTGRES_URI
else:
include_db = False
# The services below are defined in a non-intuitive order to match
# the existing unit tests for this function.
# It's fine to re-order just requires updating the unit tests, so it should
# be done with caution.
# Define the Redis service first as per the test order
services = {
"langgraph-redis": {
"image": "redis:6",
"healthcheck": {
"test": "redis-cli ping",
"interval": "5s",
"timeout": "1s",
"retries": 5,
},
}
}
# Add Postgres service before langgraph-api if it is needed
if include_db:
services["langgraph-postgres"] = {
"image": "pgvector/pgvector:pg16",
"ports": ['"5433:5432"'],
"environment": {
"POSTGRES_DB": "postgres",
"POSTGRES_USER": "postgres",
"POSTGRES_PASSWORD": "postgres",
},
"command": ["postgres", "-c", "shared_preload_libraries=vector"],
"volumes": ["langgraph-data:/var/lib/postgresql/data"],
"healthcheck": {
"test": "pg_isready -U postgres",
"start_period": "10s",
"timeout": "1s",
"retries": 5,
},
}
if capabilities.healthcheck_start_interval:
services["langgraph-postgres"]["healthcheck"]["interval"] = "60s"
services["langgraph-postgres"]["healthcheck"]["start_interval"] = "1s"
else:
services["langgraph-postgres"]["healthcheck"]["interval"] = "5s"
# Add optional debugger service if debugger_port is specified
if debugger_port:
services["langgraph-debugger"] = debugger_compose(
port=debugger_port, base_url=debugger_base_url
)["langgraph-debugger"]
# Add langgraph-api service
api_environment = {
"REDIS_URI": "redis://langgraph-redis:6379",
"POSTGRES_URI": postgres_uri,
}
if engine_runtime_mode == "distributed":
api_environment["N_JOBS_PER_WORKER"] = '"0"'
services["langgraph-api"] = {
"ports": [f'"{port}:8000"'],
"depends_on": {
"langgraph-redis": {"condition": "service_healthy"},
},
"environment": api_environment,
}
if image:
services["langgraph-api"]["image"] = image
# If Postgres is included, add it to the dependencies of langgraph-api
if include_db:
services["langgraph-api"]["depends_on"]["langgraph-postgres"] = {
"condition": "service_healthy"
}
# Additional healthcheck for langgraph-api if required
if capabilities.healthcheck_start_interval:
services["langgraph-api"]["healthcheck"] = {
"test": "python /api/healthcheck.py",
"interval": "60s",
"start_interval": "1s",
"start_period": "10s",
}
# Final compose dictionary with volumes included if needed
compose_dict = {}
if include_db:
compose_dict["volumes"] = {"langgraph-data": {"driver": "local"}}
compose_dict["services"] = services
return compose_dict
def compose(
capabilities: DockerCapabilities,
*,
port: int,
debugger_port: int | None = None,
debugger_base_url: str | None = None,
# postgres://user:password@host:port/database?option=value
postgres_uri: str | None = None,
image: str | None = None,
base_image: str | None = None,
api_version: str | None = None,
engine_runtime_mode: str = "combined_queue_worker",
) -> str:
"""Create a docker compose file as a string."""
compose_content = compose_as_dict(
capabilities,
port=port,
debugger_port=debugger_port,
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri,
image=image,
base_image=base_image,
api_version=api_version,
engine_runtime_mode=engine_runtime_mode,
)
compose_str = dict_to_yaml(compose_content)
return compose_str
def build_docker_image(
runner,
set: Callable[[str], None],
config: pathlib.Path,
config_json: dict,
base_image: str | None,
api_version: str | None,
pull: bool,
tag: str,
passthrough: Sequence[str] = (),
install_command: str | None = None,
build_command: str | None = None,
docker_command: Sequence[str] | None = None,
extra_flags: Sequence[str] = (),
verbose: bool = True,
):
"""Build a Docker image from a LangGraph config."""
# pull latest images
if pull:
runner.run(
subp_exec(
"docker",
"pull",
langgraph_cli.config.docker_tag(config_json, base_image, api_version),
verbose=verbose,
)
)
set("Building...")
# apply options
args = [
"-f",
"-", # stdin
"-t",
tag,
]
# determine build context: use current directory for JS projects, config parent for Python
is_js_project = config_json.get("node_version") and not config_json.get(
"python_version"
)
# build/install commands only apply to JS projects for now
# without install/build command, JS projects will follow the old behavior
if is_js_project and (build_command or install_command):
build_context = str(pathlib.Path.cwd())
else:
build_context = str(config.parent)
# Deep copy to avoid mutating the caller's config (config_to_docker
# rewrites graph paths to container-internal paths in place).
config_json = copy.deepcopy(config_json)
stdin, additional_contexts = langgraph_cli.config.config_to_docker(
config_path=config,
config=config_json,
base_image=base_image,
api_version=api_version,
install_command=install_command,
build_command=build_command,
build_context=build_context,
)
# add additional_contexts
if additional_contexts:
for k, v in additional_contexts.items():
args.extend(["--build-context", f"{k}={v}"])
cmd = tuple(docker_command) if docker_command else ("docker", "build")
runner.run(
subp_exec(
*cmd,
*args,
*extra_flags,
*passthrough,
build_context,
input=stdin,
verbose=verbose,
)
)
+174
View File
@@ -0,0 +1,174 @@
import asyncio
import signal
import sys
from collections.abc import Callable
from contextlib import contextmanager
from typing import cast
import click.exceptions
@contextmanager
def Runner():
if hasattr(asyncio, "Runner"):
with asyncio.Runner() as runner:
yield runner
else:
class _Runner:
def __enter__(self):
return self
def __exit__(self, *args):
pass
def run(self, coro):
return asyncio.run(coro)
yield _Runner()
async def subp_exec(
cmd: str,
*args: str,
input: str | None = None,
wait: float | None = None,
verbose: bool = False,
collect: bool = False,
on_stdout: Callable[[str], bool | None] | None = None,
) -> tuple[str | None, str | None]:
if verbose:
cmd_str = f"+ {cmd} {' '.join(map(str, args))}"
if input:
print(cmd_str, " <\n", "\n".join(filter(None, input.splitlines())), sep="")
else:
print(cmd_str)
if wait:
await asyncio.sleep(wait)
try:
proc = await asyncio.create_subprocess_exec(
cmd,
*args,
stdin=asyncio.subprocess.PIPE if input else None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
def signal_handler():
# make sure process exists, then terminate it
if proc.returncode is None:
proc.terminate()
original_sigint_handler = signal.getsignal(signal.SIGINT)
if sys.platform == "win32":
def handle_windows_signal(signum, frame):
signal_handler()
original_sigint_handler(signum, frame)
signal.signal(signal.SIGINT, handle_windows_signal)
# NOTE: we're not adding a handler for SIGTERM since it's ignored on Windows
else:
loop = asyncio.get_event_loop()
loop.add_signal_handler(signal.SIGINT, signal_handler)
loop.add_signal_handler(signal.SIGTERM, signal_handler)
empty_fut: asyncio.Future = asyncio.Future()
empty_fut.set_result(None)
stdout, stderr, _ = await asyncio.gather(
monitor_stream(
cast(asyncio.StreamReader, proc.stdout),
collect=True,
display=verbose,
on_line=on_stdout,
),
monitor_stream(
cast(asyncio.StreamReader, proc.stderr),
collect=True,
display=verbose,
),
proc._feed_stdin(input.encode()) if input else empty_fut, # type: ignore[attr-defined]
)
returncode = await proc.wait()
if (
returncode is not None
and returncode != 0 # success
and returncode != 130 # user interrupt
):
sys.stdout.write(stdout.decode() if stdout else "")
sys.stderr.write(stderr.decode() if stderr else "")
raise click.exceptions.Exit(returncode)
if collect:
return (
stdout.decode() if stdout else None,
stderr.decode() if stderr else None,
)
else:
return None, None
finally:
try:
if proc.returncode is None:
try:
proc.terminate()
except (ProcessLookupError, KeyboardInterrupt):
pass
if sys.platform == "win32":
signal.signal(signal.SIGINT, original_sigint_handler)
else:
loop.remove_signal_handler(signal.SIGINT)
loop.remove_signal_handler(signal.SIGTERM)
except UnboundLocalError:
pass
async def monitor_stream(
stream: asyncio.StreamReader,
collect: bool = False,
display: bool = False,
on_line: Callable[[str], bool | None] | None = None,
) -> bytearray | None:
if collect:
ba = bytearray()
def handle(line: bytes, overrun: bool):
nonlocal on_line
nonlocal display
if display:
sys.stdout.buffer.write(line)
if overrun:
return
if collect:
ba.extend(line)
if on_line:
if on_line(line.decode()):
on_line = None
display = True
"""Adapted from asyncio.StreamReader.readline() to handle LimitOverrunError."""
sep = b"\n"
seplen = len(sep)
while True:
try:
line = await stream.readuntil(sep)
overrun = False
except asyncio.IncompleteReadError as e:
line = e.partial
overrun = False
except asyncio.LimitOverrunError as e:
if stream._buffer.startswith(sep, e.consumed):
line = stream._buffer[: e.consumed + seplen]
else:
line = stream._buffer.clear()
overrun = True
stream._maybe_resume_transport()
await asyncio.to_thread(handle, line, overrun)
if line == b"":
break
if collect:
return ba
else:
return None
+205
View File
@@ -0,0 +1,205 @@
"""HTTP client for LangGraph host backend deployments."""
from __future__ import annotations
from typing import Any
import click
import httpx
class HostBackendError(click.ClickException):
"""Raised when the host backend returns an error response."""
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
class HostBackendClient:
"""Minimal JSON HTTP client for the host backend deployment service."""
def __init__(
self,
base_url: str,
api_key: str,
tenant_id: str | None = None,
):
if not base_url:
raise click.UsageError("Host backend URL is required")
transport = httpx.HTTPTransport(retries=3)
headers: dict[str, str] = {
"X-Api-Key": api_key,
"Accept": "application/json",
}
if tenant_id:
headers["X-Tenant-ID"] = tenant_id
self._base_url = base_url.rstrip("/")
self._client = httpx.Client(
base_url=self._base_url,
headers=headers,
transport=transport,
timeout=30,
)
def _request(
self,
method: str,
path: str,
payload: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any:
try:
resp = self._client.request(method, path, json=payload, params=params)
resp.raise_for_status()
except httpx.HTTPStatusError as err:
detail = err.response.text or str(err.response.status_code)
raise HostBackendError(
f"{method} {path} failed with status {err.response.status_code}: {detail}",
status_code=err.response.status_code,
) from None
except httpx.TransportError as err:
raise HostBackendError(str(err)) from None
if not resp.content:
return None
try:
return resp.json()
except ValueError as err:
raise HostBackendError(
f"Failed to decode response from {path}: {err}"
) from None
def create_deployment(
self,
name: str,
deployment_type: str,
source: str,
config_path: str | None = None,
secrets: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
"""Create a deployment."""
payload: dict[str, Any] = {
"name": name,
"source": source,
"source_config": {"deployment_type": deployment_type},
"source_revision_config": {},
}
if source == "internal_source" and config_path:
payload["source_revision_config"]["langgraph_config_path"] = config_path
if secrets is not None:
payload["secrets"] = secrets
return self._request("POST", "/v2/deployments", payload)
def list_deployments(self, name_contains: str = "") -> dict[str, Any]:
return self._request(
"GET",
"/v2/deployments",
params={"name_contains": name_contains},
)
def get_deployment(self, deployment_id: str) -> dict[str, Any]:
return self._request("GET", f"/v2/deployments/{deployment_id}")
def delete_deployment(self, deployment_id: str) -> None:
return self._request("DELETE", f"/v2/deployments/{deployment_id}")
def request_push_token(self, deployment_id: str) -> dict[str, Any]:
return self._request(
"POST",
f"/v2/deployments/{deployment_id}/push-token",
)
def request_upload_url(self, deployment_id: str) -> dict[str, Any]:
"""Get a signed GCS URL for uploading the source tarball."""
return self._request(
"POST",
f"/v2/deployments/{deployment_id}/upload-url",
)
def update_deployment(
self,
deployment_id: str,
image_uri: str,
secrets: list[dict[str, str]] | None = None,
tracked_packages: list[str] | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"revision_source": "internal_docker",
"source_revision_config": {"image_uri": image_uri},
}
if tracked_packages:
payload["tracked_packages"] = tracked_packages
if secrets is not None:
payload["secrets"] = secrets
return self._request(
"PATCH",
f"/v2/deployments/{deployment_id}",
payload,
)
def update_deployment_internal_source(
self,
deployment_id: str,
source_tarball_path: str,
config_path: str,
secrets: list[dict[str, str]] | None = None,
install_command: str | None = None,
build_command: str | None = None,
tracked_packages: list[str] | None = None,
) -> dict[str, Any]:
"""Trigger a remote build revision with the uploaded tarball."""
payload: dict[str, Any] = {
"revision_source": "internal_source",
"source_revision_config": {
"source_tarball_path": source_tarball_path,
"langgraph_config_path": config_path,
},
}
if tracked_packages:
payload["tracked_packages"] = tracked_packages
source_config: dict[str, Any] = {}
if install_command is not None:
source_config["install_command"] = install_command
if build_command is not None:
source_config["build_command"] = build_command
if source_config:
payload["source_config"] = source_config
if secrets is not None:
payload["secrets"] = secrets
return self._request("PATCH", f"/v2/deployments/{deployment_id}", payload)
def list_revisions(self, deployment_id: str, limit: int = 1) -> dict[str, Any]:
return self._request(
"GET",
f"/v2/deployments/{deployment_id}/revisions?limit={limit}",
)
def get_revision(self, deployment_id: str, revision_id: str) -> dict[str, Any]:
return self._request(
"GET",
f"/v2/deployments/{deployment_id}/revisions/{revision_id}",
)
def get_build_logs(
self, project_id: str, revision_id: str, payload: dict[str, Any]
) -> Any:
return self._request(
"POST",
f"/v1/projects/{project_id}/revisions/{revision_id}/build_logs",
payload,
)
def get_deploy_logs(
self,
project_id: str,
payload: dict[str, Any],
revision_id: str | None = None,
) -> Any:
if revision_id:
path = f"/v1/projects/{project_id}/revisions/{revision_id}/deploy_logs"
else:
path = f"/v1/projects/{project_id}/deploy_logs"
return self._request("POST", path, payload)
+107
View File
@@ -0,0 +1,107 @@
import sys
import threading
import time
from collections.abc import Callable
class Progress:
delay: float = 0.1
@staticmethod
def spinning_cursor():
while True:
yield from "|/-\\"
def __init__(self, *, message="", elapsed: bool = False, json_mode: bool = False):
self.message = message
self._base_message = message
self._show_elapsed = elapsed
self._json_mode = json_mode
# use this to make sure we don't kill thread when we set msg to ""
self._stop = threading.Event()
# signalled when the spinner has no text on screen
self._line_clear = threading.Event()
self._line_clear.set()
self.spinner_generator = self.spinning_cursor()
def spinner_iteration(self):
message = self.message
sys.stdout.write(next(self.spinner_generator) + " " + message)
sys.stdout.flush()
time.sleep(self.delay)
# clear the spinner and message
sys.stdout.write(
"\b" * (len(message) + 2)
+ " " * (len(message) + 2)
+ "\b" * (len(message) + 2)
)
sys.stdout.flush()
def _format_elapsed(self, seconds: float) -> str:
mins, secs = divmod(int(seconds), 60)
if mins:
return f"{self._base_message} ({mins}m {secs:02d}s)"
return f"{self._base_message} ({secs}s)"
def spinner_task(self):
start = time.monotonic()
while not self._stop.is_set():
if not self.message:
self._line_clear.set()
time.sleep(self.delay)
continue
if self._show_elapsed:
self.message = self._format_elapsed(time.monotonic() - start)
message = self.message
if not message:
self._line_clear.set()
continue
self._line_clear.clear()
sys.stdout.write(next(self.spinner_generator) + " " + message)
sys.stdout.flush()
time.sleep(self.delay)
# clear the spinner and message
sys.stdout.write(
"\b" * (len(message) + 2)
+ " " * (len(message) + 2)
+ "\b" * (len(message) + 2)
)
sys.stdout.flush()
self._line_clear.set()
def __enter__(self) -> Callable[[str], None]:
if self._json_mode:
return lambda message: None
if sys.stdout.isatty():
self.thread = threading.Thread(target=self.spinner_task)
self.thread.start()
def set_message(message):
self.message = message
self._base_message = message or self._base_message
if not message:
self._line_clear.wait(timeout=0.5)
return set_message
else:
def set_message(message):
if message:
sys.stderr.write(message + "\n")
sys.stderr.flush()
return set_message
def __exit__(self, exception, value, tb):
if self._json_mode:
return
if sys.stdout.isatty():
self.message = ""
self._stop.set()
try:
self.thread.join()
finally:
del self.thread
if exception is not None:
return False
View File
+788
View File
@@ -0,0 +1,788 @@
from typing import Any, Literal, TypedDict
from typing_extensions import Required
Distros = Literal["debian", "wolfi", "bookworm"]
MiddlewareOrders = Literal["auth_first", "middleware_first"]
class TTLConfig(TypedDict, total=False):
"""Configuration for TTL (time-to-live) behavior in the store."""
refresh_on_read: bool
"""Default behavior for refreshing TTLs on read operations (`GET` and `SEARCH`).
If `True`, TTLs will be refreshed on read operations (get/search) by default.
This can be overridden per-operation by explicitly setting `refresh_ttl`.
Defaults to `True` if not configured.
"""
default_ttl: float | None
"""Optional. Default TTL (time-to-live) in minutes for new items.
If provided, all new items will have this TTL unless explicitly overridden.
If omitted, items will have no TTL by default.
"""
sweep_interval_minutes: int | None
"""Optional. Interval in minutes between TTL sweep iterations.
If provided, the store will periodically delete expired items based on the TTL.
If omitted, no automatic sweeping will occur.
"""
class IndexConfig(TypedDict, total=False):
"""Configuration for indexing documents for semantic search in the store.
This governs how text is converted into embeddings and stored for vector-based lookups.
"""
dims: int
"""Required. Dimensionality of the embedding vectors you will store.
Must match the output dimension of your selected embedding model or custom embed function.
If mismatched, you will likely encounter shape/size errors when inserting or querying vectors.
Common embedding model output dimensions:
- openai:text-embedding-3-large: 3072
- openai:text-embedding-3-small: 1536
- openai:text-embedding-ada-002: 1536
- cohere:embed-english-v3.0: 1024
- cohere:embed-english-light-v3.0: 384
- cohere:embed-multilingual-v3.0: 1024
- cohere:embed-multilingual-light-v3.0: 384
"""
embed: str
"""Required. Identifier or reference to the embedding model or a custom embedding function.
The format can vary:
- "<provider>:<model_name>" for recognized providers (e.g., "openai:text-embedding-3-large")
- "path/to/module.py:function_name" for your own local embedding function
- "my_custom_embed" if it's a known alias in your system
Examples:
- "openai:text-embedding-3-large"
- "cohere:embed-multilingual-v3.0"
- "src/app.py:embeddings"
Note: Must return embeddings of dimension `dims`.
"""
fields: list[str] | None
"""Optional. List of JSON fields to extract before generating embeddings.
Defaults to ["$"], which means the entire JSON object is embedded as one piece of text.
If you provide multiple fields (e.g. ["title", "content"]), each is extracted and embedded separately,
often saving token usage if you only care about certain parts of the data.
Example:
fields=["title", "abstract", "author.biography"]
"""
class StoreConfig(TypedDict, total=False):
"""Configuration for the built-in long-term memory store.
This store can optionally perform semantic search. If you omit `index`,
the store will just handle traditional (non-embedded) data without vector lookups.
"""
index: IndexConfig | None
"""Optional. Defines the vector-based semantic search configuration.
If provided, the store will:
- Generate embeddings according to `index.embed`
- Enforce the embedding dimension given by `index.dims`
- Embed only specified JSON fields (if any) from `index.fields`
If omitted, no vector index is initialized.
"""
ttl: TTLConfig | None
"""Optional. Defines the TTL (time-to-live) behavior configuration.
If provided, the store will apply TTL settings according to the configuration.
If omitted, no TTL behavior is configured.
"""
class ThreadTTLConfig(TypedDict, total=False):
"""Configure a default TTL for checkpointed data within threads."""
strategy: Literal["delete", "keep_latest"]
"""Action taken when a thread exceeds its TTL.
- "delete": Remove the thread and all its data entirely.
- "keep_latest": Prune old checkpoints but keep the thread and its latest state.
"""
default_ttl: float | None
"""Default TTL (time-to-live) in minutes for checkpointed data."""
sweep_interval_minutes: int | None
"""Interval in minutes between sweep iterations.
If omitted, a default interval will be used (typically ~ 5 minutes)."""
sweep_limit: int | None
"""Maximum number of threads to process per sweep iteration. Defaults to 1000."""
class SerdeConfig(TypedDict, total=False):
"""Configuration for the built-in serde, which handles checkpointing of state.
If omitted, no serde is set up (the object store will still be present, however)."""
allowed_json_modules: list[list[str]] | bool | None
"""Optional. List of allowed python modules to de-serialize custom objects from JSON.
If provided, only the specified modules will be allowed to be deserialized.
If omitted, no modules are allowed, and the object returned will simply be a json object OR
a deserialized langchain object.
Example:
{...
"serde": {
"allowed_json_modules": [
["my_agent", "my_file", "SomeType"],
]
}
}
If you set this to True, any module will be allowed to be deserialized.
Example:
{...
"serde": {
"allowed_json_modules": True
}
}
"""
allowed_msgpack_modules: list[list[str]] | bool | None
"""Optional. List of allowed python modules to de-serialize custom objects from msgpack.
Known safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always
allowed regardless of this setting. Use this to allowlist your custom Pydantic models,
dataclasses, and other user-defined types.
If True (default), unregistered types will log a warning but still be deserialized.
If None, only known safe types will be deserialized; unregistered types will be blocked.
Example - allowlist specific types (no warnings for these):
{...
"serde": {
"allowed_msgpack_modules": [
["my_agent.models", "MyState"],
]
}
}
Example - strict mode (only safe types allowed):
{...
"serde": {
"allowed_msgpack_modules": null
}
}
"""
pickle_fallback: bool
"""Optional. Whether to allow pickling as a fallback for deserialization.
If True, pickling will be allowed as a fallback for deserialization.
If False, pickling will not be allowed as a fallback for deserialization.
Defaults to True if not configured."""
class CheckpointerConfig(TypedDict, total=False):
"""Configuration for the built-in checkpointer, which handles checkpointing of state.
If omitted, no checkpointer is set up (the object store will still be present, however).
"""
path: str
"""Import path to an async context manager that yields a `BaseCheckpointSaver`
instance.
The referenced object should be an `@asynccontextmanager`-decorated function
so that the server can properly manage the checkpointer's lifecycle (e.g.
opening and closing connections).
Examples:
- "./my_checkpointer.py:create_checkpointer"
- "my_package.checkpointer:create_checkpointer"
When provided, this replaces the default checkpointer.
You can use the `langgraph-checkpoint-conformance` package
(https://pypi.org/project/langgraph-checkpoint-conformance/) to run simple
conformance tests against your custom checkpointer and catch
incompatibilities early.
"""
ttl: ThreadTTLConfig | None
"""Optional. Defines the TTL (time-to-live) behavior configuration.
If provided, the checkpointer will apply TTL settings according to the configuration.
If omitted, no TTL behavior is configured.
"""
serde: SerdeConfig | None
"""Optional. Defines the serde configuration.
If provided, the checkpointer will apply serde settings according to the configuration.
If omitted, no serde behavior is configured.
This configuration requires server version 0.5 or later to take effect.
"""
class SecurityConfig(TypedDict, total=False):
"""Configuration for OpenAPI security definitions and requirements.
Useful for specifying global or path-level authentication and authorization flows
(e.g., OAuth2, API key headers, etc.).
"""
securitySchemes: dict[str, dict[str, Any]]
"""Describe each security scheme recognized by your OpenAPI spec.
Keys are scheme names (e.g. "OAuth2", "ApiKeyAuth") and values are their definitions.
Example:
{
"OAuth2": {
"type": "oauth2",
"flows": {
"password": {
"tokenUrl": "/token",
"scopes": {"read": "Read data", "write": "Write data"}
}
}
}
}
"""
security: list[dict[str, list[str]]]
"""Global security requirements across all endpoints.
Each element in the list maps a security scheme (e.g. "OAuth2") to a list of scopes (e.g. ["read", "write"]).
Example:
[
{"OAuth2": ["read", "write"]},
{"ApiKeyAuth": []}
]
"""
# path => {method => security}
paths: dict[str, dict[str, list[dict[str, list[str]]]]]
"""Path-specific security overrides.
Keys are path templates (e.g., "/items/{item_id}"), mapping to:
- Keys that are HTTP methods (e.g., "GET", "POST"),
- Values are lists of security definitions (just like `security`) for that method.
Example:
{
"/private_data": {
"GET": [{"OAuth2": ["read"]}],
"POST": [{"OAuth2": ["write"]}]
}
}
"""
class CacheConfig(TypedDict, total=False):
cache_keys: list[str]
"""Optional. List of header keys to use for caching.
Example:
["user_id", "workspace_id"]
"""
ttl_seconds: int
"""Optional. Time-to-live in seconds for cached items.
Example:
3600
"""
max_size: int
"""Optional. Maximum size of the cache.
Example:
100
"""
class AuthConfig(TypedDict, total=False):
"""Configuration for custom authentication logic and how it integrates into the OpenAPI spec."""
path: str
"""Required. Path to an instance of the Auth() class that implements custom authentication.
Format: "path/to/file.py:my_auth"
"""
disable_studio_auth: bool
"""Optional. Whether to disable LangSmith API-key authentication for requests originating the Studio.
Defaults to False, meaning that if a particular header is set, the server will verify the `x-api-key` header
value is a valid API key for the deployment's workspace. If `True`, all requests will go through your custom
authentication logic, regardless of origin of the request.
"""
openapi: SecurityConfig
"""The security configuration to include in your server's OpenAPI spec.
Example (OAuth2):
{
"securitySchemes": {
"OAuth2": {
"type": "oauth2",
"flows": {
"password": {
"tokenUrl": "/token",
"scopes": {"me": "Read user info", "items": "Manage items"}
}
}
}
},
"security": [
{"OAuth2": ["me"]}
]
}
"""
cache: CacheConfig
"""Optional. Cache configuration for the server.
Example:
{
"cache_keys": ["user_id", "workspace_id"],
"ttl_seconds": 3600,
"max_size": 100
}
"""
class EncryptionConfig(TypedDict, total=False):
"""Configuration for custom at-rest encryption logic.
Allows you to implement custom encryption for sensitive data stored in the database,
including metadata fields and checkpoint blobs."""
path: str
"""Required. Path to an instance of the Encryption() class that implements custom encryption handlers.
Format: "path/to/file.py:my_encryption"
Example:
{
"encryption": {
"path": "./encryption.py:my_encryption"
}
}
"""
class CorsConfig(TypedDict, total=False):
"""Specifies Cross-Origin Resource Sharing (CORS) rules for your server.
If omitted, defaults are typically very restrictive (often no cross-origin requests).
Configure carefully if you want to allow usage from browsers hosted on other domains.
"""
allow_origins: list[str]
"""Optional. List of allowed origins (e.g., "https://example.com").
Default is often an empty list (no external origins).
Use "*" only if you trust all origins, as that bypasses most restrictions.
"""
allow_methods: list[str]
"""Optional. HTTP methods permitted for cross-origin requests (e.g. ["GET", "POST"]).
Default might be ["GET", "POST", "OPTIONS"] depending on your server framework.
"""
allow_headers: list[str]
"""Optional. HTTP headers that can be used in cross-origin requests (e.g. ["Content-Type", "Authorization"])."""
allow_credentials: bool
"""Optional. If `True`, cross-origin requests can include credentials (cookies, auth headers).
Default False to avoid accidentally exposing secured endpoints to untrusted sites.
"""
allow_origin_regex: str
"""Optional. A regex pattern for matching allowed origins, used if you have dynamic subdomains.
Example: "^https://.*\\.mycompany\\.com$"
"""
expose_headers: list[str]
"""Optional. List of headers that browsers are allowed to read from the response in cross-origin contexts."""
max_age: int
"""Optional. How many seconds the browser may cache preflight responses.
Default might be 600 (10 minutes). Larger values reduce preflight requests but can cause stale configurations.
"""
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.
Exclusions (if provided) take precedence.
Each value can be a raw string with an optional wildcard.
"""
includes: list[str] | None
"""Headers to include (if not also matched against an 'excludes' pattern).
Examples:
- 'user-agent'
- 'x-configurable-*'
"""
excludes: list[str] | None
"""Headers to exclude. Applied before the 'includes' checks.
Examples:
- 'x-api-key'
- '*key*'
- '*token*'
"""
class HttpConfig(TypedDict, total=False):
"""Configuration for the built-in HTTP server that powers your deployment's routes and endpoints."""
app: str
"""Optional. Import path to a custom Starlette/FastAPI application to mount.
Format: "path/to/module.py:app_var"
If provided, it can override or extend the default routes.
"""
disable_assistants: bool
"""Optional. If `True`, /assistants routes are removed from the server.
Default is False (meaning /assistants is enabled).
"""
disable_threads: bool
"""Optional. If `True`, /threads routes are removed.
Default is False.
"""
disable_runs: bool
"""Optional. If `True`, /runs routes are removed.
Default is False.
"""
disable_store: bool
"""Optional. If `True`, /store routes are removed, disabling direct store interactions via HTTP.
Default is False.
"""
disable_mcp: bool
"""Optional. If `True`, /mcp routes are removed, disabling default support to expose the deployment as an MCP server.
Default is False.
"""
disable_a2a: bool
"""Optional. If `True`, /a2a routes are removed, disabling default support to expose the deployment as an agent-to-agent (A2A) server.
Default is False.
"""
disable_meta: bool
"""Optional. Remove meta endpoints.
Set to True to disable the following endpoints: /openapi.json, /info, /metrics, /docs.
This will also make the /ok endpoint skip any DB or other checks, always returning {"ok": True}.
Default is False.
"""
disable_ui: bool
"""Optional. If `True`, /ui routes are removed, disabling the UI server.
Default is False.
"""
disable_webhooks: bool
"""Optional. If `True`, webhooks are disabled. Runs created with an associated webhook will
still be executed, but the webhook event will not be sent.
Default is False.
"""
cors: CorsConfig | None
"""Optional. Defines CORS restrictions. If omitted, no special rules are set and
cross-origin behavior depends on default server settings.
"""
configurable_headers: ConfigurableHeaderConfig | None
"""Optional. Defines how headers are treated for a run's configuration.
You can include or exclude headers as configurable values to condition your
agent's behavior or permissions on a request's headers."""
logging_headers: ConfigurableHeaderConfig | None
"""Optional. Defines which headers are excluded from logging."""
middleware_order: MiddlewareOrders | None
"""Optional. Defines the order in which to apply server customizations.
Choices:
- "auth_first": Authentication hooks (custom or default) are evaluated
before custom middleware.
- "middleware_first": Custom middleware is evaluated
before authentication hooks (custom or default).
Default is `middleware_first`.
"""
enable_custom_route_auth: bool
"""Optional. If `True`, authentication is enabled for custom routes,
not just the routes that are protected by default.
(Routes protected by default include /assistants, /threads, and /runs).
Default is False. This flag only affects authentication behavior
if `app` is provided and contains custom routes.
"""
mount_prefix: str
"""Optional. URL prefix to prepend to all the routes.
Example:
"/api"
"""
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 GraphDef(TypedDict, total=False):
"""Definition of a graph with additional metadata."""
path: str
"""Required. Import path to the graph object.
Format: "path/to/file.py:object_name"
"""
description: str | None
"""Optional. A description of the graph's purpose and functionality.
This description is surfaced in the API and can help users understand what the graph does.
"""
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 UvSource(TypedDict, total=False):
"""Deployment source rooted at a uv project or workspace."""
kind: Required[Literal["uv"]]
"""Discriminator for uv-backed deployment mode."""
root: str
"""Relative path from langgraph.json to the authoritative uv project root.
The resolved directory must contain `pyproject.toml` and `uv.lock`. If the
root is a workspace, package discovery happens within this root.
"""
package: str
"""Optional. Workspace package name to deploy when the target is ambiguous.
If omitted, the CLI tries to infer the target package from the location of
`langgraph.json`, or falls back to the only package if the root contains
exactly one candidate.
"""
class Config(TypedDict, total=False):
"""Top-level config for langgraph-cli or similar deployment tooling."""
python_version: str
"""Optional. Python version in 'major.minor' format (e.g. '3.11').
Must be at least 3.11 or greater for this deployment to function properly.
"""
node_version: str | None
"""Optional. Node.js version as a major version (e.g. '20'), if your deployment needs Node.
Must be >= 20 if provided.
"""
api_version: str | None
"""Optional. Which semantic version of the LangGraph API server to use.
Defaults to latest. Check the
[changelog](https://docs.langchain.com/langgraph-platform/langgraph-server-changelog)
for more information."""
_INTERNAL_docker_tag: str | None
"""Optional. Internal use only.
"""
base_image: str | None
"""Optional. Base image to use for the LangGraph API server.
Defaults to langchain/langgraph-api or langchain/langgraphjs-api."""
image_distro: Distros | None
"""Optional. Linux distribution for the base image.
Must be one of 'wolfi', 'debian', or 'bookworm'.
If omitted, defaults to 'debian' ('latest').
"""
pip_config_file: str | None
"""Optional. Path to a pip config file (e.g., "/etc/pip.conf" or "pip.ini") for controlling
package installation (custom indices, credentials, etc.).
Only relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.
"""
pip_installer: str | None
"""Optional. Python package installer to use ('auto', 'pip', or 'uv').
- 'auto' (default): Use uv for supported base images, otherwise pip
- 'pip': Force use of pip regardless of base image support
- 'uv': Force use of uv (will fail if base image doesn't support it)
"""
source: UvSource | None
"""Optional. Explicit deployment source configuration.
Use `{ "kind": "uv", "root": "." }` to deploy from a uv project rooted at
`root/pyproject.toml` and `root/uv.lock`. If `root` is a workspace and the
target is ambiguous, set `package` to the desired workspace member.
"""
dockerfile_lines: list[str]
"""Optional. Additional Docker instructions that will be appended to your base Dockerfile.
Useful for installing OS packages, setting environment variables, etc.
Example:
dockerfile_lines=[
"RUN apt-get update && apt-get install -y libmagic-dev",
"ENV MY_CUSTOM_VAR=hello_world"
]
"""
dependencies: list[str]
"""List of Python dependencies to install, either from PyPI or local paths.
Examples:
- "." or "./src" if you have a local Python package
- str (aka "anthropic") for a PyPI package
- "git+https://github.com/org/repo.git@main" for a Git-based package
Defaults to an empty list, meaning no additional packages installed beyond your base environment.
This field is not supported when `source.kind` is `uv`.
"""
graphs: dict[str, str | GraphDef]
"""Optional. Named definitions of graphs, each pointing to a Python object.
Graphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context
managers that accept a single configuration argument (of type RunnableConfig) and return a pregel object
(instance of Stategraph, etc.).
Keys are graph names, values are either "path/to/file.py:object_name" strings
or objects with a "path" key and optional "description" key.
Example:
{
"mygraph": "graphs/my_graph.py:graph_definition",
"anothergraph": {
"path": "graphs/another.py:get_graph",
"description": "A graph that does X"
}
}
"""
env: dict[str, str] | str
"""Optional. Environment variables to set for your deployment.
- If given as a dict, keys are variable names and values are their values.
- If given as a string, it must be a path to a file containing lines in KEY=VALUE format.
Example as a dict:
env={"API_TOKEN": "abc123", "DEBUG": "true"}
Example as a file path:
env=".env"
"""
store: StoreConfig | None
"""Optional. Configuration for the built-in long-term memory store, including semantic search indexing.
If omitted, no vector index is set up (the object store will still be present, however).
"""
checkpointer: CheckpointerConfig | None
"""Optional. Configuration for the built-in checkpointer, which handles checkpointing of state.
If omitted, no checkpointer is set up (the object store will still be present, however).
"""
auth: AuthConfig | None
"""Optional. Custom authentication config, including the path to your Python auth logic and
the OpenAPI security definitions it uses.
"""
encryption: EncryptionConfig | None
"""Optional. Custom at-rest encryption config, including the path to your Python encryption logic.
Allows you to implement custom encryption for sensitive data stored in the database.
"""
http: HttpConfig | None
"""Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed
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.
"""
keep_pkg_tools: bool | list[str] | None
"""Optional. Control whether to retain Python packaging tools in the final image.
Allowed tools are: "pip", "setuptools", "wheel".
You can also set to true to include all packaging tools.
"""
__all__ = [
"Config",
"GraphDef",
"StoreConfig",
"CheckpointerConfig",
"AuthConfig",
"EncryptionConfig",
"HttpConfig",
"MiddlewareOrders",
"Distros",
"TTLConfig",
"IndexConfig",
]
+186
View File
@@ -0,0 +1,186 @@
import os
import shutil
import sys
from io import BytesIO
from urllib import error, request
from zipfile import ZipFile
import click
TEMPLATES: dict[str, dict[str, str]] = {
"Deep Agent": {
"description": "An opinionated deployment template for a Deep Agent.",
"python": "https://github.com/langchain-ai/deep-agent-template/archive/refs/heads/main.zip",
"js": "https://github.com/langchain-ai/deep-agent-template-js/archive/refs/heads/main.zip",
},
"Agent": {
"description": "A simple agent that can be flexibly extended to many tools.",
"python": "https://github.com/langchain-ai/simple-agent-template/archive/refs/heads/main.zip",
},
"New LangGraph Project": {
"description": "A simple, minimal chatbot with memory.",
"python": "https://github.com/langchain-ai/new-langgraph-project/archive/refs/heads/main.zip",
"js": "https://github.com/langchain-ai/new-langgraphjs-project/archive/refs/heads/main.zip",
},
}
# Generate TEMPLATE_IDS programmatically
TEMPLATE_ID_TO_CONFIG = {
f"{name.lower().replace(' ', '-')}-{lang}": (name, lang, url)
for name, versions in TEMPLATES.items()
for lang, url in versions.items()
if lang in {"python", "js"}
}
TEMPLATE_IDS = list(TEMPLATE_ID_TO_CONFIG.keys())
TEMPLATE_HELP_STRING = (
"The name of the template to use. Available options:\n"
+ "\n".join(f"{id_}" for id_ in TEMPLATE_ID_TO_CONFIG)
)
def _choose_template() -> str:
"""Presents a list of templates to the user and prompts them to select one.
Returns:
str: The URL of the selected template.
"""
click.secho("🌟 Please select a template:", bold=True, fg="yellow")
for idx, (template_name, template_info) in enumerate(TEMPLATES.items(), 1):
click.secho(f"{idx}. ", nl=False, fg="cyan")
click.secho(template_name, fg="cyan", nl=False)
click.secho(f" - {template_info['description']}", fg="white")
# Get the template choice from the user, defaulting to the first template if blank
template_choice: int | None = click.prompt(
"Enter the number of your template choice (default is 1)",
type=int,
default=1,
show_default=False,
)
template_keys = list(TEMPLATES.keys())
if 1 <= template_choice <= len(template_keys):
selected_template: str = template_keys[template_choice - 1]
else:
click.secho("❌ Invalid choice. Please try again.", fg="red")
return _choose_template()
template_info = TEMPLATES[selected_template]
available_langs = [lang for lang in ("python", "js") if lang in template_info]
click.secho(
f"\nYou selected: {selected_template} - {template_info['description']}",
fg="green",
)
if len(available_langs) == 1:
return template_info[available_langs[0]]
version_choice: int = click.prompt(
"Choose language (1 for Python 🐍, 2 for JS/TS 🌐)", type=int
)
if version_choice == 1:
return template_info["python"]
elif version_choice == 2:
return template_info["js"]
else:
click.secho("❌ Invalid choice. Please try again.", fg="red")
return _choose_template()
def _download_repo_with_requests(repo_url: str, path: str) -> None:
"""Download a ZIP archive from the given URL and extracts it to the specified path.
Args:
repo_url: The URL of the repository to download.
path: The path where the repository should be extracted.
"""
click.secho("📥 Attempting to download repository as a ZIP archive...", fg="yellow")
click.secho(f"URL: {repo_url}", fg="yellow")
try:
with request.urlopen(repo_url) as response:
if response.status == 200:
with ZipFile(BytesIO(response.read())) as zip_file:
zip_file.extractall(path)
# Move extracted contents to path
for item in os.listdir(path):
if item.endswith("-main"):
extracted_dir = os.path.join(path, item)
for filename in os.listdir(extracted_dir):
shutil.move(os.path.join(extracted_dir, filename), path)
shutil.rmtree(extracted_dir)
click.secho(
f"✅ Downloaded and extracted repository to {path}", fg="green"
)
except error.HTTPError as e:
click.secho(
f"❌ Error: Failed to download repository.\nDetails: {e}\n",
fg="red",
bold=True,
err=True,
)
sys.exit(1)
def create_new(path: str | None, template: str | None) -> None:
"""Create a new LangGraph project at the specified PATH using the chosen TEMPLATE.
Args:
path: The path where the new project will be created.
template: The name of the template to use.
"""
# Prompt for path if not provided
if not path:
path = click.prompt(
"📂 Please specify the path to create the application", default="."
)
path = os.path.abspath(path) # Ensure path is absolute
# Check if path exists and is not empty
if os.path.exists(path) and os.listdir(path):
click.secho(
"❌ The specified directory already exists and is not empty. "
"Aborting to prevent overwriting files.",
fg="red",
bold=True,
)
sys.exit(1)
# Get template URL either from command-line argument or
# through interactive selection
if template:
if template not in TEMPLATE_ID_TO_CONFIG:
# Format available options in a readable way with descriptions
template_options = ""
for id_ in TEMPLATE_IDS:
name, lang, _ = TEMPLATE_ID_TO_CONFIG[id_]
description = TEMPLATES[name]["description"]
# Add each template option with color formatting
template_options += (
click.style("- ", fg="yellow", bold=True)
+ click.style(f"{id_}", fg="cyan")
+ click.style(f": {description}", fg="white")
+ "\n"
)
# Display error message with colors and formatting
click.secho("❌ Error:", fg="red", bold=True, nl=False)
click.secho(f" Template '{template}' not found.", fg="red")
click.secho(
"Please select from the available options:\n", fg="yellow", bold=True
)
click.secho(template_options, fg="cyan")
sys.exit(1)
_, _, template_url = TEMPLATE_ID_TO_CONFIG[template]
else:
template_url = _choose_template()
# Download and extract the template
_download_repo_with_requests(template_url, path)
click.secho(f"🎉 New project created at {path}", fg="green", bold=True)
+50
View File
@@ -0,0 +1,50 @@
"""General-purpose utilities shared across the LangGraph CLI."""
from collections.abc import Callable
import click
def clean_empty_lines(input_str: str):
return "\n".join(filter(None, input_str.splitlines()))
def warn_non_wolfi_distro(
config_json: dict,
*,
emit: Callable[[str], None] | None = None,
) -> None:
"""Show warning if image_distro is not set to 'wolfi'.
When ``emit`` is provided, each warning line is sent through it (used by
callers that need JSON-aware output). Otherwise falls back to colored
``click.secho`` output.
"""
image_distro = config_json.get("image_distro", "debian") # Default is debian
if image_distro == "wolfi":
return
if emit is not None:
emit(
"⚠️ Security Recommendation: Consider switching to Wolfi Linux for enhanced security."
)
emit(
" Wolfi is a security-oriented, minimal Linux distribution designed for containers."
)
emit(
' To switch, add \'"image_distro": "wolfi"\' to your langgraph.json config file.'
)
return
click.secho(
"⚠️ Security Recommendation: Consider switching to Wolfi Linux for enhanced security.",
fg="yellow",
bold=True,
)
click.secho(
" Wolfi is a security-oriented, minimal Linux distribution designed for containers.",
fg="yellow",
)
click.secho(
' To switch, add \'"image_distro": "wolfi"\' to your langgraph.json config file.',
fg="yellow",
)
click.secho("") # Empty line for better readability
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
"""Main entrypoint into package."""
from importlib import metadata
try:
__version__ = metadata.version(__package__)
except metadata.PackageNotFoundError:
# Case where package metadata is not available.
__version__ = ""
del metadata # optional, avoids polluting the results of dir(__package__)