Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions libs/cli/langgraph_cli/cli.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""CLI entrypoint for LangGraph API server."""

import base64
Expand Down Expand Up @@ -1511,6 +1511,17 @@
"""Prepare the arguments and stdin for running the LangGraph API server."""
config_json = langgraph_cli.config.validate_config_file(config_path)
warn_non_wolfi_distro(config_json)

if engine_runtime_mode == "distributed" and not api_version and not image:
click.secho(
"Resolving latest published version for distributed runtime...",
fg="cyan",
)
api_version = langgraph_cli.config.fetch_latest_api_version()
click.secho(
f"Using version {api_version} for all distributed images.", fg="cyan"
)

# pull latest images
if pull:
runner.run(
Expand All @@ -1535,6 +1546,14 @@
verbose=verbose,
)
)
runner.run(
subp_exec(
"docker",
"pull",
f"langchain/langgraph-orchestrator-licensed:{api_version}",
verbose=verbose,
)
)

args, stdin = prepare_args_and_stdin(
capabilities=capabilities,
Expand Down
41 changes: 40 additions & 1 deletion libs/cli/langgraph_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pathlib
import re
import textwrap
import urllib.request
from collections import Counter
from typing import Literal, NamedTuple

Expand Down Expand Up @@ -1233,6 +1234,37 @@ def node_config_to_docker(
return os.linesep.join(docker_file_contents), {}


VERSION_MARKER_REPO = "langchain/langgraph-published-version-marker"
_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+")


def fetch_latest_api_version() -> str:
"""Fetch the latest published API version from the Docker Hub version marker.

The marker repo is tagged with ``<semver>``, ``<sha>``, and ``latest``.
We pick the first tag that looks like a semver version.
"""
url = f"https://hub.docker.com/v2/repositories/{VERSION_MARKER_REPO}/tags/?page_size=10"
try:
with urllib.request.urlopen(url, timeout=10) as resp:
data = json.loads(resp.read())
except Exception as exc:
raise click.ClickException(
f"Failed to fetch latest API version from {VERSION_MARKER_REPO}: {exc}\n"
"You can specify the version explicitly with --api-version."
) from exc

for tag in data.get("results", []):
name = tag.get("name", "")
if _VERSION_RE.match(name):
return name

raise click.ClickException(
f"Could not find a semver tag in {VERSION_MARKER_REPO}.\n"
"You can specify the version explicitly with --api-version."
)


def default_base_image(
config: Config, engine_runtime_mode: str = "combined_queue_worker"
) -> str:
Expand Down Expand Up @@ -1422,9 +1454,16 @@ def config_to_compose(
additional_contexts:
{executor_additional_contexts_str}"""

if not api_version:
raise click.ClickException(
"Distributed runtime requires a pinned API version for all images.\n"
"Either pass --api-version explicitly or let the CLI resolve it "
"from the Docker Hub version marker."
)

postgres_uri = "postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
result += f""" langgraph-orchestrator:
image: langchain/langgraph-orchestrator-licensed:latest
image: langchain/langgraph-orchestrator-licensed:{api_version}
depends_on:
langgraph-api:
condition: service_healthy
Expand Down
23 changes: 23 additions & 0 deletions libs/cli/tests/unit_tests/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,7 @@ def test_prepare_args_and_stdin_distributed_mode() -> None:
port=port,
watch=False,
engine_runtime_mode="distributed",
api_version="0.7.67",
)

# API service should use langgraph-api base image
Expand All @@ -958,3 +959,25 @@ def test_prepare_args_and_stdin_distributed_mode() -> None:
assert "langgraph-executor:" in actual_stdin
assert "FROM langchain/langgraph-executor:" in actual_stdin
assert "executor_entrypoint.sh" in actual_stdin


def test_prepare_args_and_stdin_distributed_with_api_version() -> None:
"""All 3 images should use the same api_version in distributed mode."""
config_path = pathlib.Path(__file__).parent / "langgraph.json"
config = validate_config(
Config(dependencies=["."], graphs={"agent": "agent.py:graph"})
)
actual_args, actual_stdin = prepare_args_and_stdin(
capabilities=DEFAULT_DOCKER_CAPABILITIES,
config_path=config_path,
config=config,
docker_compose=None,
port=8000,
watch=False,
engine_runtime_mode="distributed",
api_version="0.7.67",
)

assert "FROM langchain/langgraph-api:0.7.67-py3.11" in actual_stdin
assert "FROM langchain/langgraph-executor:0.7.67-py3.11" in actual_stdin
assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual_stdin
105 changes: 99 additions & 6 deletions libs/cli/tests/unit_tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
config_to_docker,
default_base_image,
docker_tag,
fetch_latest_api_version,
has_disallowed_build_command_content,
validate_config,
validate_config_file,
Expand Down Expand Up @@ -1772,19 +1773,23 @@ def test_config_to_compose_distributed_mode():
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
engine_runtime_mode="distributed",
api_version="0.7.67",
)

# API service uses langchain/langgraph-api base image
assert "FROM langchain/langgraph-api:3.11" in actual_compose_stdin
assert "FROM langchain/langgraph-api:0.7.67-py3.11" in actual_compose_stdin

# Orchestrator service is present
# Orchestrator service is present with pinned version
assert "langgraph-orchestrator:" in actual_compose_stdin
assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual_compose_stdin
assert "EXECUTOR_TARGET: langgraph-executor:8188" in actual_compose_stdin

# Executor service is present with correct base image
assert "langgraph-executor:" in actual_compose_stdin
assert "FROM langchain/langgraph-executor:3.11" in actual_compose_stdin
assert 'entrypoint: ["sh", "/storage/executor_entrypoint.sh"]' in actual_compose_stdin
assert "FROM langchain/langgraph-executor:0.7.67-py3.11" in actual_compose_stdin
assert (
'entrypoint: ["sh", "/storage/executor_entrypoint.sh"]' in actual_compose_stdin
)

# Executor has required environment variables
assert "EXECUTOR_GRPC_PORT:" in actual_compose_stdin
Expand All @@ -1802,6 +1807,7 @@ def test_config_to_compose_distributed_mode_with_env_file():
validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}),
"langchain/langgraph-api",
engine_runtime_mode="distributed",
api_version="0.7.67",
)

# env_file should appear multiple times: API, orchestrator, executor
Expand All @@ -1820,6 +1826,7 @@ def test_config_to_compose_distributed_mode_generates_two_dockerfiles():
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
engine_runtime_mode="distributed",
api_version="0.7.67",
)

# Should contain two different FROM lines
Expand All @@ -1829,8 +1836,8 @@ def test_config_to_compose_distributed_mode_generates_two_dockerfiles():
if line.strip().startswith("FROM ")
]
assert len(from_lines) == 2
assert "FROM langchain/langgraph-api:3.11" in from_lines[0]
assert "FROM langchain/langgraph-executor:3.11" in from_lines[1]
assert "FROM langchain/langgraph-api:0.7.67-py3.11" in from_lines[0]
assert "FROM langchain/langgraph-executor:0.7.67-py3.11" in from_lines[1]


def test_config_to_compose_combined_mode_no_orchestrator():
Expand Down Expand Up @@ -1869,6 +1876,7 @@ def test_config_to_compose_distributed_executor_gets_correct_paths():
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
engine_runtime_mode="distributed",
api_version="0.7.67",
)

# Both API and executor Dockerfiles should contain valid LANGSERVE_GRAPHS
Expand All @@ -1884,6 +1892,91 @@ def test_config_to_compose_distributed_executor_gets_correct_paths():
)


def test_fetch_latest_api_version_parses_semver(monkeypatch):
"""fetch_latest_api_version should return the first semver-like tag."""
import io
import urllib.request

fake_body = json.dumps(
{"results": [{"name": "latest"}, {"name": "abc1234"}, {"name": "0.7.67"}]}
).encode()

def mock_urlopen(url, *, timeout=None):
return io.BytesIO(fake_body)

monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
assert fetch_latest_api_version() == "0.7.67"


def test_fetch_latest_api_version_no_semver_raises(monkeypatch):
"""Should raise ClickException when no semver tag is found."""
import io
import urllib.request

fake_body = json.dumps(
{"results": [{"name": "latest"}, {"name": "abc1234"}]}
).encode()

def mock_urlopen(url, *, timeout=None):
return io.BytesIO(fake_body)

monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
with pytest.raises(click.ClickException, match="Could not find a semver tag"):
fetch_latest_api_version()


def test_fetch_latest_api_version_network_error_raises(monkeypatch):
"""Should raise ClickException on network failure."""
import urllib.request

def mock_urlopen(url, *, timeout=None):
raise urllib.error.URLError("connection refused")

monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
with pytest.raises(click.ClickException, match="Failed to fetch"):
fetch_latest_api_version()


def test_config_to_compose_distributed_orchestrator_uses_api_version():
"""Orchestrator image tag should use the api_version when provided."""
graphs = {"agent": "./agent.py:graph"}
actual = config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
engine_runtime_mode="distributed",
api_version="0.7.67",
)
assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual


def test_config_to_compose_distributed_requires_api_version():
"""Without api_version, distributed mode should raise ClickException."""
graphs = {"agent": "./agent.py:graph"}
with pytest.raises(click.ClickException, match="pinned API version"):
config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
engine_runtime_mode="distributed",
)


def test_config_to_compose_distributed_all_images_same_version():
"""All 3 images (api, executor, orchestrator) should use the same api_version."""
graphs = {"agent": "./agent.py:graph"}
actual = config_to_compose(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
engine_runtime_mode="distributed",
api_version="0.7.67",
)
assert "FROM langchain/langgraph-api:0.7.67-py3.11" in actual
assert "FROM langchain/langgraph-executor:0.7.67-py3.11" in actual
assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual


class TestHasDisallowedBuildCommandContent:
"""Tests for has_disallowed_build_command_content."""

Expand Down