Compare commits

..
Author SHA1 Message Date
John Kennedyandopen-swe[bot] <open-swe@users.noreply.github.com> 8e05912899 fix(cli): extend API healthcheck startup window
Keep one-second health probes active for slower graph imports so Compose does not stall for the steady-state interval.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-08-20 07:24:09 +00:00
John KennedyandGitHub f899af1c73 Merge branch 'main' into langster/remove-stale-debugger-pull 2026-08-20 00:07:35 -07:00
John Kennedy 5f479f1af5 fix(cli): preserve hosted Studio URL overrides 2026-08-11 09:39:33 -07:00
John Kennedy 33c3edfde2 fix(cli): drop unrelated uv export change 2026-08-11 09:39:24 -07:00
John Kennedyandopen-swe[bot] <open-swe@users.noreply.github.com> 292fd5787c fix(cli): include workspace metadata in uv exports
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-08-10 23:03:38 +00:00
John Kennedyandopen-swe[bot] <open-swe@users.noreply.github.com> b904db211c fix(cli): update integration runner arguments
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-08-10 22:53:15 +00:00
langsmith-fleet[bot] ecee22fa16 fix(cli): remove discontinued local debugger image 2026-08-06 00:01:58 +00:00
21 changed files with 196 additions and 560 deletions
+1 -3
View File
@@ -32,8 +32,6 @@ def test(config: pathlib.Path, port: int, tag: str, verbose: bool):
docker_compose=None,
port=port,
watch=False,
debugger_port=None,
debugger_base_url=f"http://127.0.0.1:{port}",
postgres_uri=None,
api_version=None,
image=tag,
@@ -173,5 +171,5 @@ if __name__ == "__main__":
except BaseException:
logger.exception("Test failed")
raise
logger.info("Test execution finished")
-2
View File
@@ -76,8 +76,6 @@ __pypackages__/
# Environments
.env
.env.*
*.crt
.envrc
*.crt
*.key
-8
View File
@@ -2,14 +2,6 @@
This repository is a monorepo. Each library lives in a subdirectory under `libs/`.
<corridor>
## Corridor security analysis
When Corridor's `analyzePlan` tool is available, create a plan and use the tool to analyze it before generating or modifying code. Apply the resulting security guidance before writing code.
</corridor>
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
-8
View File
@@ -2,14 +2,6 @@
This repository is a monorepo. Each library lives in a subdirectory under `libs/`.
<corridor>
## Corridor security analysis
Before generating or modifying code, create a plan and use Corridor's `analyzePlan` tool to analyze it. Apply the resulting security guidance before writing code.
</corridor>
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
-4
View File
@@ -23,8 +23,6 @@ from langgraph_cli.schemas import (
GraphDef,
HttpConfig,
IndexConfig,
McpConfig,
McpTasksConfig,
SecurityConfig,
SerdeConfig,
StoreConfig,
@@ -117,8 +115,6 @@ def add_descriptions_to_schema(schema, cls):
AuthConfig,
SecurityConfig,
HttpConfig,
McpConfig,
McpTasksConfig,
CorsConfig,
CacheConfig,
ThreadTTLConfig,
-3
View File
@@ -48,9 +48,6 @@ def get_anonymized_params(
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
+89 -34
View File
@@ -5,6 +5,7 @@ import pathlib
import shutil
import sys
from collections.abc import Sequence
from urllib.parse import SplitResult, urlencode, urlsplit, urlunsplit
import click
import click.exceptions
@@ -140,17 +141,6 @@ OPT_VERBOSE = click.option(
help="Show more output from the server logs",
)
OPT_WATCH = click.option("--watch", is_flag=True, help="Restart on file changes")
OPT_DEBUGGER_PORT = click.option(
"--debugger-port",
type=int,
help="Pull the debugger image locally and serve the UI on specified port",
)
OPT_DEBUGGER_BASE_URL = click.option(
"--debugger-base-url",
type=str,
help="URL used by the debugger to access LangGraph API. Defaults to http://127.0.0.1:[PORT]",
)
OPT_POSTGRES_URI = click.option(
"--postgres-uri",
help="Postgres URI to use for the database. Defaults to launching a local database",
@@ -242,18 +232,94 @@ cli.add_command(deploy)
# ---------------------------------------------------------------------------
def _validated_http_url(value: str, option_name: str) -> SplitResult:
try:
parsed = urlsplit(value)
hostname = parsed.hostname
_ = parsed.port
except ValueError as exc:
raise click.UsageError(
f"{option_name} must be a valid HTTP(S) URL without credentials."
) from exc
if (
value != value.strip()
or parsed.scheme not in {"http", "https"}
or not parsed.netloc
or not hostname
or parsed.username is not None
or parsed.password is not None
):
raise click.UsageError(
f"{option_name} must be a valid HTTP(S) URL without credentials."
)
return parsed
def _studio_link(
*,
port: int,
studio_url: str | None,
api_url: str | None,
debugger_base_url: str | None,
) -> str:
if debugger_base_url is not None:
if api_url is not None and api_url != debugger_base_url:
raise click.UsageError(
"--api-url and --debugger-base-url cannot specify different URLs."
)
click.echo(
"Warning: --debugger-base-url is deprecated; use --api-url instead.",
err=True,
)
api_url = debugger_base_url
studio_url = "https://smith.langchain.com" if studio_url is None else studio_url
api_url = f"http://127.0.0.1:{port}" if api_url is None else api_url
studio_parts = _validated_http_url(studio_url, "--studio-url")
_validated_http_url(api_url, "--api-url")
if studio_parts.query or studio_parts.fragment:
raise click.UsageError(
"--studio-url must not include a query string or fragment."
)
studio_path = f"{studio_parts.path.rstrip('/')}/studio/"
return urlunsplit(
studio_parts._replace(
path=studio_path,
query=urlencode({"baseUrl": api_url}),
)
)
@OPT_RECREATE
@OPT_PULL
@OPT_PORT
@OPT_DOCKER_COMPOSE
@OPT_CONFIG
@OPT_VERBOSE
@OPT_DEBUGGER_PORT
@OPT_DEBUGGER_BASE_URL
@OPT_WATCH
@OPT_POSTGRES_URI
@OPT_API_VERSION
@OPT_ENGINE_RUNTIME_MODE
@click.option(
"--studio-url",
type=str,
default=None,
help="URL of the LangGraph Studio instance. Defaults to https://smith.langchain.com",
)
@click.option(
"--api-url",
type=str,
default=None,
help="URL that LangGraph Studio uses to access the API. Defaults to http://127.0.0.1:[PORT]",
)
@click.option(
"--debugger-base-url",
type=str,
default=None,
hidden=True,
)
@click.option(
"--image",
type=str,
@@ -284,14 +350,21 @@ def up(
watch: bool,
wait: bool,
verbose: bool,
debugger_port: int | None,
debugger_base_url: str | None,
postgres_uri: str | None,
api_version: str | None,
engine_runtime_mode: str,
studio_url: str | None,
api_url: str | None,
debugger_base_url: str | None,
image: str | None,
base_image: str | None,
):
studio_link = _studio_link(
port=port,
studio_url=studio_url,
api_url=api_url,
debugger_base_url=debugger_base_url,
)
click.secho("Starting LangGraph API server...", fg="green")
click.secho(
"""For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment.
@@ -308,8 +381,6 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE
pull=pull,
watch=watch,
verbose=verbose,
debugger_port=debugger_port,
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri,
api_version=api_version,
engine_runtime_mode=engine_runtime_mode,
@@ -337,20 +408,12 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE
if "unpacking to docker.io" in line:
set("Starting...")
elif "Application startup complete" in line:
debugger_origin = (
f"http://localhost:{debugger_port}"
if debugger_port
else "https://smith.langchain.com"
)
debugger_base_url_query = (
debugger_base_url or f"http://127.0.0.1:{port}"
)
set("")
sys.stdout.write(
f"""Ready!
- API: http://localhost:{port}
- Docs: http://localhost:{port}/docs
- LangGraph Studio: {debugger_origin}/studio/?baseUrl={debugger_base_url_query}
- LangGraph Studio: {studio_link}
"""
)
sys.stdout.flush()
@@ -935,8 +998,6 @@ def prepare_args_and_stdin(
docker_compose: pathlib.Path | None,
port: int,
watch: bool,
debugger_port: int | None = None,
debugger_base_url: str | None = None,
postgres_uri: str | None = None,
api_version: str | None = None,
engine_runtime_mode: str = "combined_queue_worker",
@@ -950,8 +1011,6 @@ def prepare_args_and_stdin(
stdin = langgraph_cli.docker.compose(
capabilities,
port=port,
debugger_port=debugger_port,
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri,
image=image,
base_image=base_image,
@@ -989,8 +1048,6 @@ def prepare(
pull: bool,
watch: bool,
verbose: bool,
debugger_port: int | None = None,
debugger_base_url: str | None = None,
postgres_uri: str | None = None,
api_version: str | None = None,
engine_runtime_mode: str = "combined_queue_worker",
@@ -1032,8 +1089,6 @@ def prepare(
docker_compose=docker_compose,
port=port,
watch=watch,
debugger_port=debugger_port,
debugger_base_url=debugger_base_url or f"http://127.0.0.1:{port}",
postgres_uri=postgres_uri,
api_version=api_version,
engine_runtime_mode=engine_runtime_mode,
+1 -36
View File
@@ -142,29 +142,6 @@ def check_capabilities(runner) -> DockerCapabilities:
)
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."""
@@ -191,8 +168,6 @@ 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
@@ -253,12 +228,6 @@ def compose_as_dict(
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",
@@ -289,7 +258,7 @@ def compose_as_dict(
"test": "python /api/healthcheck.py",
"interval": "60s",
"start_interval": "1s",
"start_period": "10s",
"start_period": "60s",
}
# Final compose dictionary with volumes included if needed
@@ -305,8 +274,6 @@ 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,
@@ -318,8 +285,6 @@ def compose(
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,
-20
View File
@@ -439,24 +439,6 @@ class ConfigurableHeaderConfig(TypedDict, total=False):
"""
class McpTasksConfig(TypedDict, total=False):
"""Settings for authenticated MCP elicitation tasks."""
poll_interval_ms: int
"""Suggested client polling interval, in milliseconds. Default 1000; range 10060000."""
ttl_minutes: int
"""Maximum task lifetime, in minutes. Default 1440; range 110080."""
completed_grace_seconds: int
"""Result retention after completion, in seconds. Default 300; range 186400."""
allow_insecure_urls: bool
"""Allow HTTP elicitation URLs for development. Defaults to False; production URLs require HTTPS."""
class McpConfig(TypedDict, total=False):
tasks: McpTasksConfig
"""Polling, retention and URL settings for the MCP Tasks extension."""
class HttpConfig(TypedDict, total=False):
"""Configuration for the built-in HTTP server that powers your deployment's routes and endpoints."""
@@ -491,8 +473,6 @@ class HttpConfig(TypedDict, total=False):
Default is False.
"""
mcp: McpConfig
"""Optional. Configure the MCP Tasks extension for elicitation over protocol 2026-07-28."""
disable_a2a: bool
"""Optional. If `True`, /a2a routes are removed, disabling default support to expose the deployment as an agent-to-agent (A2A) server.
-40
View File
@@ -1065,10 +1065,6 @@
],
"description": "Optional. Defines which headers are excluded from logging."
},
"mcp": {
"$ref": "#/$defs/McpConfig",
"description": "Optional. Configure the MCP Tasks extension for elicitation over protocol 2026-07-28."
},
"middleware_order": {
"anyOf": [
{
@@ -1174,42 +1170,6 @@
},
"required": []
},
"McpConfig": {
"title": "McpConfig",
"type": "object",
"properties": {
"tasks": {
"$ref": "#/$defs/McpTasksConfig",
"description": "Polling, retention and URL settings for the MCP Tasks extension."
}
},
"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)"
},
"McpTasksConfig": {
"title": "McpTasksConfig",
"description": "Settings for authenticated MCP elicitation tasks.",
"type": "object",
"properties": {
"allow_insecure_urls": {
"type": "boolean",
"description": "Allow HTTP elicitation URLs for development. Defaults to False; production URLs require HTTPS."
},
"completed_grace_seconds": {
"type": "integer",
"description": "Result retention after completion, in seconds. Default 300; range 1\u201386400."
},
"poll_interval_ms": {
"type": "integer",
"description": "Suggested client polling interval, in milliseconds. Default 1000; range 100\u201360000."
},
"ttl_minutes": {
"type": "integer",
"description": "Maximum task lifetime, in minutes. Default 1440; range 1\u201310080."
}
},
"required": []
},
"UvSource": {
"title": "UvSource",
"description": "Deployment source rooted at a uv project or workspace.",
-40
View File
@@ -1065,10 +1065,6 @@
],
"description": "Optional. Defines which headers are excluded from logging."
},
"mcp": {
"$ref": "#/$defs/McpConfig",
"description": "Optional. Configure the MCP Tasks extension for elicitation over protocol 2026-07-28."
},
"middleware_order": {
"anyOf": [
{
@@ -1174,42 +1170,6 @@
},
"required": []
},
"McpConfig": {
"title": "McpConfig",
"type": "object",
"properties": {
"tasks": {
"$ref": "#/$defs/McpTasksConfig",
"description": "Polling, retention and URL settings for the MCP Tasks extension."
}
},
"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)"
},
"McpTasksConfig": {
"title": "McpTasksConfig",
"description": "Settings for authenticated MCP elicitation tasks.",
"type": "object",
"properties": {
"allow_insecure_urls": {
"type": "boolean",
"description": "Allow HTTP elicitation URLs for development. Defaults to False; production URLs require HTTPS."
},
"completed_grace_seconds": {
"type": "integer",
"description": "Result retention after completion, in seconds. Default 300; range 1\u201386400."
},
"poll_interval_ms": {
"type": "integer",
"description": "Suggested client polling interval, in milliseconds. Default 1000; range 100\u201360000."
},
"ttl_minutes": {
"type": "integer",
"description": "Maximum task lifetime, in minutes. Default 1440; range 1\u201310080."
}
},
"required": []
},
"UvSource": {
"title": "UvSource",
"description": "Deployment source rooted at a uv project or workspace.",
+80 -31
View File
@@ -8,10 +8,11 @@ from contextlib import contextmanager
from pathlib import Path
import click
import pytest
from click.testing import CliRunner
import langgraph_cli.deploy as deploy_module
from langgraph_cli.cli import cli, prepare_args_and_stdin
from langgraph_cli.cli import _studio_link, cli, prepare_args_and_stdin
from langgraph_cli.config import Config, _get_pip_cleanup_lines, validate_config
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
from langgraph_cli.util import clean_empty_lines
@@ -56,8 +57,6 @@ def test_prepare_args_and_stdin() -> None:
Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"})
)
port = 8000
debugger_port = 8001
debugger_graph_url = f"http://127.0.0.1:{port}"
actual_args, actual_stdin = prepare_args_and_stdin(
capabilities=DEFAULT_DOCKER_CAPABILITIES,
@@ -65,8 +64,6 @@ def test_prepare_args_and_stdin() -> None:
config=config,
docker_compose=pathlib.Path("custom-docker-compose.yml"),
port=port,
debugger_port=debugger_port,
debugger_base_url=debugger_graph_url,
watch=True,
)
@@ -110,16 +107,6 @@ services:
retries: 5
interval: 60s
start_interval: 1s
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
depends_on:
langgraph-postgres:
condition: service_healthy
ports:
- "{debugger_port}:3968"
environment:
VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
langgraph-api:
ports:
- "8000:8000"
@@ -135,7 +122,7 @@ services:
test: python /api/healthcheck.py
interval: 60s
start_interval: 1s
start_period: 10s
start_period: 60s
pull_policy: build
build:
@@ -178,8 +165,6 @@ def test_prepare_args_and_stdin_with_image() -> None:
Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"})
)
port = 8000
debugger_port = 8001
debugger_graph_url = f"http://127.0.0.1:{port}"
actual_args, actual_stdin = prepare_args_and_stdin(
capabilities=DEFAULT_DOCKER_CAPABILITIES,
@@ -187,8 +172,6 @@ def test_prepare_args_and_stdin_with_image() -> None:
config=config,
docker_compose=pathlib.Path("custom-docker-compose.yml"),
port=port,
debugger_port=debugger_port,
debugger_base_url=debugger_graph_url,
watch=True,
image="my-cool-image",
)
@@ -233,16 +216,6 @@ services:
retries: 5
interval: 60s
start_interval: 1s
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
depends_on:
langgraph-postgres:
condition: service_healthy
ports:
- "{debugger_port}:3968"
environment:
VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
langgraph-api:
ports:
- "8000:8000"
@@ -259,7 +232,7 @@ services:
test: python /api/healthcheck.py
interval: 60s
start_interval: 1s
start_period: 10s
start_period: 60s
develop:
@@ -289,6 +262,82 @@ def test_version_option() -> None:
)
def test_up_help_shows_hosted_studio_options() -> None:
result = CliRunner().invoke(cli, ["up", "--help"])
assert result.exit_code == 0, result.output
assert "--studio-url" in result.output
assert "--api-url" in result.output
assert "--debugger-port" not in result.output
assert "--debugger-base-url" not in result.output
def test_studio_link_defaults_to_hosted_studio() -> None:
assert _studio_link(
port=8123,
studio_url=None,
api_url=None,
debugger_base_url=None,
) == ("https://smith.langchain.com/studio/?baseUrl=http%3A%2F%2F127.0.0.1%3A8123")
def test_studio_link_supports_self_hosted_and_remote_urls() -> None:
assert _studio_link(
port=8123,
studio_url="https://langsmith.example.com/prefix/",
api_url="https://api.example.com/graph?tenant=a&region=eu",
debugger_base_url=None,
) == (
"https://langsmith.example.com/prefix/studio/"
"?baseUrl=https%3A%2F%2Fapi.example.com%2Fgraph%3Ftenant%3Da%26region%3Deu"
)
def test_studio_link_supports_deprecated_debugger_base_url(capsys) -> None:
assert _studio_link(
port=8123,
studio_url=None,
api_url=None,
debugger_base_url="https://api.example.com",
).endswith("?baseUrl=https%3A%2F%2Fapi.example.com")
assert "--debugger-base-url is deprecated; use --api-url" in capsys.readouterr().err
@pytest.mark.parametrize(
("studio_url", "api_url"),
[
("javascript:alert(1)", None),
("https://user:password@example.com", None),
("https://smith.langchain.com?workspace=test", None),
(None, "file:///tmp/langgraph.sock"),
(None, "https://user:password@example.com"),
],
)
def test_studio_link_rejects_unsafe_urls(
studio_url: str | None, api_url: str | None
) -> None:
with pytest.raises(click.UsageError):
_studio_link(
port=8123,
studio_url=studio_url,
api_url=api_url,
debugger_base_url=None,
)
def test_studio_link_rejects_conflicting_api_url_aliases() -> None:
with pytest.raises(
click.UsageError,
match="cannot specify different URLs",
):
_studio_link(
port=8123,
studio_url=None,
api_url="https://api.example.com",
debugger_base_url="https://other.example.com",
)
def test_top_level_help_shows_deploy_subcommands() -> None:
runner = CliRunner()
+4 -98
View File
@@ -16,7 +16,7 @@ DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities(
)
def test_compose_with_no_debugger_and_custom_db():
def test_compose_with_custom_db():
port = 8123
custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose(
@@ -42,7 +42,7 @@ def test_compose_with_no_debugger_and_custom_db():
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_no_debugger_and_custom_db_with_healthcheck():
def test_compose_with_custom_db_and_healthcheck():
port = 8123
custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose(
@@ -71,39 +71,11 @@ def test_compose_with_no_debugger_and_custom_db_with_healthcheck():
test: python /api/healthcheck.py
interval: 60s
start_interval: 1s
start_period: 10s"""
start_period: 60s"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_debugger_and_custom_db():
port = 8123
custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES,
port=port,
postgres_uri=custom_postgres_uri,
)
expected_compose_str = f"""services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-api:
ports:
- "{port}:8000"
depends_on:
langgraph-redis:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: {custom_postgres_uri}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_debugger_and_default_db():
def test_compose_with_default_db():
port = 8123
actual_compose_str = compose(DEFAULT_DOCKER_CAPABILITIES, port=port)
expected_compose_str = f"""volumes:
@@ -302,72 +274,6 @@ def test_compose_with_api_version_and_custom_postgres():
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_api_version_and_debugger():
"""Test compose function with api_version and debugger port."""
port = 8123
debugger_port = 8001
api_version = "0.2.74"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES,
port=port,
api_version=api_version,
debugger_port=debugger_port,
)
expected_compose_str = f"""volumes:
langgraph-data:
driver: local
services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
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
interval: 5s
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
depends_on:
langgraph-postgres:
condition: service_healthy
ports:
- "{debugger_port}:3968"
langgraph-api:
ports:
- "{port}:8000"
depends_on:
langgraph-redis:
condition: service_healthy
langgraph-postgres:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: {DEFAULT_POSTGRES_URI}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_distributed_mode_with_custom_db():
"""Test compose with engine_runtime_mode='distributed' adds N_JOBS_PER_WORKER=0."""
port = 8123
-9
View File
@@ -39,15 +39,6 @@
- `client.threads.stream()` now accepts `transport="sse"` (default) or
`transport="websocket"` in place of the previous transport-agnostic default.
### Fixed
- Resource-scoped auth decorators now honor `actions=` and reject empty or
invalid action lists. Because unmatched custom-auth paths remain allowed,
deployments using action-scoped handlers should configure a global
default-deny handler; `langgraph-api` 0.10+ warns about uncovered paths at
startup. Resource-specific decorators retain matching `resources=` selectors
for backward compatibility; use `@auth.on(resources=...)` for other resources.
### Notes
- The v3 streaming surface (`AsyncThreadStream`, `SyncThreadStream`, and all
+1 -1
View File
@@ -3,7 +3,7 @@ from langgraph_sdk.client import get_client, get_sync_client
from langgraph_sdk.encryption import Encryption
from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext
__version__ = "0.4.4"
__version__ = "0.4.3"
__all__ = [
"Auth",
+1 -4
View File
@@ -24,7 +24,7 @@ from langchain_core.language_models.chat_model_stream import AsyncChatModelStrea
from langchain_protocol import Event, SubscribeParams
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk.schema import LangSmithTracing, QueryParamTypes
from langgraph_sdk.schema import QueryParamTypes
from langgraph_sdk.stream.controller import _SeenEventIds
from langgraph_sdk.stream.decoders import (
DataDecoder,
@@ -172,7 +172,6 @@ class RunModule:
input: Any = None,
config: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
langsmith_tracing: LangSmithTracing | None = None,
) -> dict[str, Any]:
"""Send `run.start` to the server. Returns the result (`{"run_id": ...}`)."""
params: dict[str, Any] = {"assistant_id": self._owner.assistant_id}
@@ -182,8 +181,6 @@ class RunModule:
params["config"] = config
if metadata is not None:
params["metadata"] = metadata
if langsmith_tracing is not None:
params["langsmith_tracer"] = langsmith_tracing
loop = asyncio.get_running_loop()
gate: asyncio.Future[None] = loop.create_future()
self._owner._run_start_ready = gate
+1 -4
View File
@@ -23,7 +23,7 @@ from langchain_core.language_models.chat_model_stream import ChatModelStream
from langchain_protocol import Event, SubscribeParams
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk.schema import LangSmithTracing, QueryParamTypes
from langgraph_sdk.schema import QueryParamTypes
from langgraph_sdk.stream.decoders import (
DataDecoder,
Decoder,
@@ -215,7 +215,6 @@ class SyncRunModule:
input: Any = None,
config: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
langsmith_tracing: LangSmithTracing | None = None,
) -> dict[str, Any]:
"""Send `run.start` to the server. Returns the result (`{"run_id": ...}`)."""
params: dict[str, Any] = {"assistant_id": self._owner.assistant_id}
@@ -225,8 +224,6 @@ class SyncRunModule:
params["config"] = config
if metadata is not None:
params["metadata"] = metadata
if langsmith_tracing is not None:
params["langsmith_tracer"] = langsmith_tracing
result = self._owner._send_command("run.start", params)
self._owner._run_seen = True
controller = self._owner._controller
+16 -67
View File
@@ -341,15 +341,9 @@ VUpdate = typing.TypeVar("VUpdate", covariant=True)
VRead = typing.TypeVar("VRead", covariant=True)
VDelete = typing.TypeVar("VDelete", covariant=True)
VSearch = typing.TypeVar("VSearch", covariant=True)
ResourceActionT = typing.TypeVar("ResourceActionT", bound=str)
_ResourceAction = typing.Literal["create", "read", "update", "delete", "search"]
_ThreadAction = _ResourceAction | typing.Literal["create_run"]
class _ResourceOn(
typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch, ResourceActionT]
):
class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
"""
Generic base class for resource-specific handlers.
"""
@@ -398,8 +392,8 @@ class _ResourceOn(
def __call__(
self,
*,
resources: str | Sequence[str] | None = None,
actions: ResourceActionT | Sequence[ResourceActionT] | None = None,
resources: str | Sequence[str],
actions: str | Sequence[str] | None = None,
) -> Callable[
[_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]],
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
@@ -414,7 +408,7 @@ class _ResourceOn(
) = None,
*,
resources: str | Sequence[str] | None = None,
actions: ResourceActionT | Sequence[ResourceActionT] | None = None,
actions: str | Sequence[str] | None = None,
) -> (
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]
| Callable[
@@ -422,66 +416,24 @@ class _ResourceOn(
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
]
):
if fn is not None:
_validate_handler(fn)
return typing.cast(
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
_register_handler(self.auth, self.resource, "*", fn),
)
def decorator(
handler: _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
) -> _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]:
_validate_handler(handler)
if resources is None:
resource_list = [self.resource]
elif isinstance(resources, str):
resource_list = [resources]
elif isinstance(resources, Sequence):
resource_list = list(resources)
else:
raise TypeError("resources must be a string or sequence of strings")
if resource_list != [self.resource]:
raise ValueError(
f"Resource-specific decorator for {self.resource!r} cannot "
f"register handlers for {resource_list!r}. Use @auth.on(...) "
"for other or multiple resources."
)
if actions is None:
action_list = ["*"]
elif isinstance(actions, str):
action_list = [actions]
elif isinstance(actions, Sequence):
action_list = list(actions)
else:
raise TypeError("actions must be a string or sequence of strings")
if not action_list:
raise ValueError("actions must not be empty")
if not all(isinstance(action, str) for action in action_list):
raise TypeError("actions must be a string or sequence of strings")
valid_actions = {
value.action
for value in vars(self).values()
if isinstance(value, _ResourceActionOn)
}
invalid_actions = (
sorted(set(action_list) - valid_actions) if actions is not None else []
return typing.cast(
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
_register_handler(self.auth, self.resource, "*", handler),
)
if invalid_actions:
raise ValueError(
f"Invalid action(s) for {self.resource}: {', '.join(invalid_actions)}"
)
if len(action_list) != len(set(action_list)):
raise ValueError("actions must not contain duplicates")
for action in action_list:
if (self.resource, action) in self.auth._handlers:
raise ValueError(
f"types.Handler already set for {self.resource}, {action}."
)
for action in action_list:
_register_handler(self.auth, self.resource, action, handler)
return handler
if fn is not None:
return decorator(
typing.cast(
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
fn,
)
)
# Accept keyword-only parameters for future filtering behavior; referenced to satisfy linters.
_ = resources, actions
return decorator
@@ -492,7 +444,6 @@ class _AssistantsOn(
types.AssistantsUpdate,
types.AssistantsDelete,
types.AssistantsSearch,
_ResourceAction,
]
):
value = (
@@ -516,7 +467,6 @@ class _ThreadsOn(
types.ThreadsUpdate,
types.ThreadsDelete,
types.ThreadsSearch,
_ThreadAction,
]
):
value = (
@@ -552,7 +502,6 @@ class _CronsOn(
types.CronsUpdate,
types.CronsDelete,
types.CronsSearch,
_ResourceAction,
]
):
value = type[
@@ -426,17 +426,11 @@ def test_sync_run_start_sends_command():
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
result = thread.run.start(
input={"x": 1},
langsmith_tracing={"project_name": "replica-project"},
)
result = thread.run.start(input={"x": 1})
assert result == {"run_id": "run-1"}
assert fake.received_commands[0]["method"] == "run.start"
assert fake.received_commands[0]["params"]["assistant_id"] == "agent"
assert fake.received_commands[0]["params"]["langsmith_tracer"] == {
"project_name": "replica-project"
}
def test_sync_events_iterates_raw_events():
@@ -287,7 +287,7 @@ async def test_command_ids_are_monotonic():
assert [c["id"] for c in fake.received_commands] == [1, 2]
async def test_run_start_forwards_config_metadata_and_langsmith_tracing():
async def test_run_start_forwards_config_and_metadata():
fake = FakeServer()
transport = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
@@ -297,18 +297,10 @@ async def test_run_start_forwards_config_metadata_and_langsmith_tracing():
input={"x": 1},
config={"recursion_limit": 5},
metadata={"trace": "abc"},
langsmith_tracing={
"project_name": "replica-project",
"example_id": "example-1",
},
)
params = fake.received_commands[0]["params"]
assert params["config"] == {"recursion_limit": 5}
assert params["metadata"] == {"trace": "abc"}
assert params["langsmith_tracer"] == {
"project_name": "replica-project",
"example_id": "example-1",
}
async def test_run_start_raises_outside_context_manager():
-132
View File
@@ -1,132 +0,0 @@
import pytest
from langgraph_sdk import Auth
def test_handler_multiple_resources_and_actions() -> None:
auth = Auth()
@auth.on(resources=["threads", "assistants"], actions=["read", "search"])
async def allow_reads(ctx, value):
del value
return {"owner": ctx.user.identity}
assert auth._handlers == {
("threads", "read"): [allow_reads],
("threads", "search"): [allow_reads],
("assistants", "read"): [allow_reads],
("assistants", "search"): [allow_reads],
}
def test_resource_handler_actions_are_scoped() -> None:
auth = Auth()
@auth.on
async def deny_all(ctx, value):
del ctx, value
return False
@auth.on.threads(actions=["create", "search"])
async def handler(ctx, value):
del ctx, value
return None
@auth.on.threads(actions="create_run")
async def run_handler(ctx, value):
del ctx, value
return None
assert auth._handlers == {
("threads", "create"): [handler],
("threads", "search"): [handler],
("threads", "create_run"): [run_handler],
}
assert auth._global_handlers == [deny_all]
def test_resource_handler_preserves_wildcard() -> None:
auth = Auth()
@auth.on.threads
async def handler(ctx, value):
del ctx, value
return None
assert auth._handlers == {("threads", "*"): [handler]}
def test_resource_handler_preserves_wildcard_with_parentheses() -> None:
auth = Auth()
@auth.on.threads()
async def handler(ctx, value):
del ctx, value
return None
assert auth._handlers == {("threads", "*"): [handler]}
def test_resource_handler_accepts_matching_resource() -> None:
auth = Auth()
@auth.on.threads(resources=["threads"], actions="read")
async def handler(ctx, value):
del ctx, value
return None
assert auth._handlers == {("threads", "read"): [handler]}
@pytest.mark.parametrize(
"resources", [["assistants"], ["threads", "assistants"], [], [1]]
)
def test_resource_handler_rejects_nonmatching_resources(resources) -> None:
auth = Auth()
async def handler(ctx, value):
del ctx, value
return None
with pytest.raises(ValueError, match=r"Use @auth\.on"):
auth.on.threads(resources=resources)(handler)
assert auth._handlers == {}
@pytest.mark.parametrize(
("resource", "actions", "error"),
[
("threads", [], ValueError),
("threads", ["reed"], ValueError),
("threads", ["create", "create"], ValueError),
("threads", {"create": True}, TypeError),
("crons", ["create_run"], ValueError),
],
)
def test_resource_handler_rejects_invalid_actions(resource, actions, error) -> None:
auth = Auth()
async def handler(ctx, value):
del ctx, value
return None
with pytest.raises(error):
getattr(auth.on, resource)(actions=actions)(handler)
assert auth._handlers == {}
def test_resource_handler_registration_is_atomic() -> None:
auth = Auth()
@auth.on.threads.read
async def read_handler(ctx, value):
del ctx, value
return None
async def handler(ctx, value):
del ctx, value
return None
with pytest.raises(ValueError, match="already set"):
auth.on.threads(actions=["create", "read"])(handler)
assert auth._handlers == {("threads", "read"): [read_handler]}