From 5c4af31372376227613909837f8ed46545932be3 Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Mon, 9 Mar 2026 21:47:11 -0700 Subject: [PATCH 1/6] version --- libs/cli/langgraph_cli/cli.py | 17 ++++ libs/cli/langgraph_cli/config.py | 41 ++++++++- libs/cli/tests/unit_tests/cli/test_cli.py | 22 +++++ libs/cli/tests/unit_tests/test_config.py | 100 ++++++++++++++++++++-- 4 files changed, 174 insertions(+), 6 deletions(-) diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index f487070cab..b884212ce9 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -1511,6 +1511,15 @@ def prepare( """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( @@ -1535,6 +1544,14 @@ def prepare( 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, diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 106daccc72..8ea7dedbf7 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -4,6 +4,7 @@ import pathlib import re import textwrap +import urllib.request from collections import Counter from typing import Literal, NamedTuple @@ -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 ````, ````, 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: @@ -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 diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 801b83ab09..76b6b1ae3c 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -958,3 +958,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 diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index b71dd76c8b..241fb1c264 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -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, @@ -1772,18 +1773,20 @@ 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 "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 @@ -1802,6 +1805,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 @@ -1820,6 +1824,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 @@ -1829,8 +1834,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(): @@ -1884,6 +1889,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_orchestrator_defaults_to_latest(): + """Without api_version, orchestrator image should use 'latest'.""" + graphs = {"agent": "./agent.py:graph"} + actual = config_to_compose( + PATH_TO_CONFIG, + validate_config({"dependencies": ["."], "graphs": graphs}), + "langchain/langgraph-api", + engine_runtime_mode="distributed", + ) + assert "langchain/langgraph-orchestrator-licensed:latest" in actual + + +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.""" From 4b4b91c7e7a1bb061f622eeb64bfff0763837a00 Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Mon, 9 Mar 2026 22:04:29 -0700 Subject: [PATCH 2/6] fixtest --- libs/cli/langgraph_cli/cli.py | 4 +++- libs/cli/tests/unit_tests/cli/test_cli.py | 1 + libs/cli/tests/unit_tests/test_config.py | 23 +++++++++++++---------- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index b884212ce9..578bb69cca 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -1518,7 +1518,9 @@ def prepare( fg="cyan", ) api_version = langgraph_cli.config.fetch_latest_api_version() - click.secho(f"Using version {api_version} for all distributed images.", fg="cyan") + click.secho( + f"Using version {api_version} for all distributed images.", fg="cyan" + ) # pull latest images if pull: diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 76b6b1ae3c..f835b1971f 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -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 diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 241fb1c264..92b8cfe135 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -1787,7 +1787,9 @@ def test_config_to_compose_distributed_mode(): # Executor service is present with correct base image assert "langgraph-executor:" 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 + assert ( + 'entrypoint: ["sh", "/storage/executor_entrypoint.sh"]' in actual_compose_stdin + ) # Executor has required environment variables assert "EXECUTOR_GRPC_PORT:" in actual_compose_stdin @@ -1874,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 @@ -1947,16 +1950,16 @@ def test_config_to_compose_distributed_orchestrator_uses_api_version(): assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual -def test_config_to_compose_distributed_orchestrator_defaults_to_latest(): - """Without api_version, orchestrator image should use 'latest'.""" +def test_config_to_compose_distributed_requires_api_version(): + """Without api_version, distributed mode should raise ClickException.""" graphs = {"agent": "./agent.py:graph"} - actual = config_to_compose( - PATH_TO_CONFIG, - validate_config({"dependencies": ["."], "graphs": graphs}), - "langchain/langgraph-api", - engine_runtime_mode="distributed", - ) - assert "langchain/langgraph-orchestrator-licensed:latest" in actual + 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(): From db40389fbdb43304de0ca108757e11063c12160d Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Tue, 10 Mar 2026 10:03:32 -0700 Subject: [PATCH 3/6] WIP: chore(cli): support DR for deploy command (#7098) - [ ] **Add tests and docs**: If you're adding a new integration, you must include: 1. A test for the integration, preferably unit tests that do not rely on network access, 2. An example notebook showing its use. It lives in `docs/docs/integrations` directory. - [ ] **Lint and test**: Run `make format`, `make lint` and `make test` from the root of the package(s) you've modified. We will not consider a PR unless these three are passing in CI. See [contribution guidelines](https://docs.langchain.com/oss/python/contributing/overview) for more. Additional guidelines: - Make sure optional dependencies are imported within a function. - Please do not add dependencies to `pyproject.toml` files (even optional ones) unless they are **required** for unit tests. - Most PRs should not touch more than one package. - Changes should be backwards compatible. --- libs/cli/langgraph_cli/cli.py | 21 +++++- libs/cli/langgraph_cli/host_backend.py | 6 ++ libs/cli/tests/unit_tests/cli/test_cli.py | 9 ++- .../cli/tests/unit_tests/test_host_backend.py | 73 +++++++++++++++++++ 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 578bb69cca..32edb185ee 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -13,6 +13,7 @@ import time from collections.abc import Callable, Sequence from contextlib import contextmanager +from typing import Any import click import click.exceptions @@ -715,6 +716,13 @@ def deploy( secrets = _secrets_from_env(env_vars) + # Determine language and runtime mode from config for host backend + is_python = bool(config_json.get("python_version")) or not config_json.get( + "node_version" + ) + deploy_engine_runtime_mode = "distributed" if is_python else "combined_queue_server" + deploy_api_version = api_version or config_json.get("api_version") + # Use buildx to cross-compile for amd64 when running on a non-x86_64 host # (e.g. Apple Silicon). On amd64 hosts, plain docker build is sufficient. needs_buildx = platform.machine() != "x86_64" @@ -858,13 +866,16 @@ def log_step(message: str) -> None: if needs_creation: log_step(f"{step}. Creating deployment '{name}'") - payload = { + payload: dict[str, Any] = { "name": name, "source": "internal_docker", "source_config": {"deployment_type": deployment_type}, "source_revision_config": {}, "secrets": secrets, + "engine_runtime_mode": deploy_engine_runtime_mode, } + if deploy_api_version: + payload["deployed_api_version"] = deploy_api_version created = client.create_deployment(payload) created_id = created.get("id") if isinstance(created, dict) else None if not isinstance(created_id, str) or not created_id: @@ -973,7 +984,13 @@ def log_step(message: str) -> None: # -- Step: Update deployment -- log_step(f"{step}. Updating deployment {deployment_id}") - updated = client.update_deployment(deployment_id, remote_image, secrets=secrets) + updated = client.update_deployment( + deployment_id, + remote_image, + secrets=secrets, + engine_runtime_mode=deploy_engine_runtime_mode, + deployed_api_version=deploy_api_version, + ) tenant_id = updated.get("tenant_id") if isinstance(updated, dict) else None if tenant_id: status_url = ( diff --git a/libs/cli/langgraph_cli/host_backend.py b/libs/cli/langgraph_cli/host_backend.py index f53055051d..40e8f133c1 100644 --- a/libs/cli/langgraph_cli/host_backend.py +++ b/libs/cli/langgraph_cli/host_backend.py @@ -82,12 +82,18 @@ def update_deployment( deployment_id: str, image_uri: str, secrets: list[dict[str, str]] | None = None, + engine_runtime_mode: str | None = None, + deployed_api_version: str | None = None, ) -> dict[str, Any]: payload: dict[str, Any] = { "source_revision_config": {"image_uri": image_uri}, } if secrets is not None: payload["secrets"] = secrets + if engine_runtime_mode is not None: + payload["engine_runtime_mode"] = engine_runtime_mode + if deployed_api_version is not None: + payload["deployed_api_version"] = deployed_api_version return self._request( "PATCH", f"/v2/deployments/{deployment_id}", diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index f835b1971f..48572f4ae2 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -946,18 +946,19 @@ def test_prepare_args_and_stdin_distributed_mode() -> None: api_version="0.7.67", ) - # API service should use langgraph-api base image - assert "FROM langchain/langgraph-api:" in actual_stdin + # API service should use langgraph-api base image with pinned version + assert "FROM langchain/langgraph-api:0.7.67-py3.11" in actual_stdin # Distributed mode sets N_JOBS_PER_WORKER=0 on the API service assert 'N_JOBS_PER_WORKER: "0"' in actual_stdin - # Orchestrator service present + # Orchestrator service present with pinned version assert "langgraph-orchestrator:" in actual_stdin + assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual_stdin # Executor service present with correct base image assert "langgraph-executor:" in actual_stdin - assert "FROM langchain/langgraph-executor:" in actual_stdin + assert "FROM langchain/langgraph-executor:0.7.67-py3.11" in actual_stdin assert "executor_entrypoint.sh" in actual_stdin diff --git a/libs/cli/tests/unit_tests/test_host_backend.py b/libs/cli/tests/unit_tests/test_host_backend.py index 3e91d45531..0a08a46680 100644 --- a/libs/cli/tests/unit_tests/test_host_backend.py +++ b/libs/cli/tests/unit_tests/test_host_backend.py @@ -152,6 +152,79 @@ def test_update_deployment_no_secrets(client): assert result == {"ok": True} +def test_update_deployment_with_engine_runtime_mode(): + """Verify engine_runtime_mode is included in the PATCH payload.""" + import json + + def handler(req: httpx.Request) -> httpx.Response: + body = json.loads(req.content) + assert body["engine_runtime_mode"] == "distributed" + assert body["source_revision_config"]["image_uri"] == "img:v1" + return httpx.Response(200, json={"id": "dep-1"}) + + c = HostBackendClient("https://api.example.com", "test-key") + c._client = httpx.Client( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + headers={"X-Api-Key": "test-key", "Accept": "application/json"}, + timeout=30, + ) + result = c.update_deployment( + "dep-1", "img:v1", engine_runtime_mode="distributed" + ) + assert result == {"id": "dep-1"} + + +def test_update_deployment_with_deployed_api_version(): + """Verify deployed_api_version is included in the PATCH payload.""" + import json + + def handler(req: httpx.Request) -> httpx.Response: + body = json.loads(req.content) + assert body["deployed_api_version"] == "0.3.5" + assert body["engine_runtime_mode"] == "distributed" + assert body["source_revision_config"]["image_uri"] == "img:v2" + assert body["secrets"] == [{"name": "K", "value": "V"}] + return httpx.Response(200, json={"id": "dep-2"}) + + c = HostBackendClient("https://api.example.com", "test-key") + c._client = httpx.Client( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + headers={"X-Api-Key": "test-key", "Accept": "application/json"}, + timeout=30, + ) + result = c.update_deployment( + "dep-2", + "img:v2", + secrets=[{"name": "K", "value": "V"}], + engine_runtime_mode="distributed", + deployed_api_version="0.3.5", + ) + assert result == {"id": "dep-2"} + + +def test_update_deployment_omits_none_fields(): + """Verify None values for optional fields are not sent in payload.""" + import json + + def handler(req: httpx.Request) -> httpx.Response: + body = json.loads(req.content) + assert "engine_runtime_mode" not in body + assert "deployed_api_version" not in body + assert "secrets" not in body + return httpx.Response(200, json={"ok": True}) + + c = HostBackendClient("https://api.example.com", "test-key") + c._client = httpx.Client( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + headers={"X-Api-Key": "test-key", "Accept": "application/json"}, + timeout=30, + ) + c.update_deployment("dep-3", "img:v1") + + def test_list_revisions(client): result = client.list_revisions("dep-123", limit=5) assert result == {"ok": True} From 6bd3e8b1e5b732cfc9d434977dc14d1dc9a5b38a Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Tue, 10 Mar 2026 10:24:46 -0700 Subject: [PATCH 4/6] version-done --- libs/cli/langgraph_cli/api_version.py | 95 +++++++++++ libs/cli/langgraph_cli/cli.py | 16 +- libs/cli/langgraph_cli/config.py | 37 +---- libs/cli/tests/unit_tests/cli/test_cli.py | 22 ++- libs/cli/tests/unit_tests/test_api_version.py | 156 ++++++++++++++++++ libs/cli/tests/unit_tests/test_config.py | 46 ------ .../cli/tests/unit_tests/test_host_backend.py | 4 +- 7 files changed, 278 insertions(+), 98 deletions(-) create mode 100644 libs/cli/langgraph_cli/api_version.py create mode 100644 libs/cli/tests/unit_tests/test_api_version.py diff --git a/libs/cli/langgraph_cli/api_version.py b/libs/cli/langgraph_cli/api_version.py new file mode 100644 index 0000000000..b349820ad9 --- /dev/null +++ b/libs/cli/langgraph_cli/api_version.py @@ -0,0 +1,95 @@ +"""Resolve the LangGraph API version from CLI flags and langgraph.json.""" + +import json +import pathlib +import re +import urllib.request + +import click + +VERSION_MARKER_REPO = "langchain/langgraph-published-version-marker" +_PATCH_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") +_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+") + + +def resolve_langgraph_api_version( + config_path: pathlib.Path, + api_version_cli_param: str | None, +) -> str: + """Resolve the API version from the CLI flag and/or langgraph.json. + + Returns the resolved patch-level version string. When neither source + provides a version, the latest published version is fetched from Docker Hub. + + Raises `click.ClickException` when both sources specify a version, or + when a version cannot be resolved via Docker Hub. + """ + api_version_langgraph_json = _read_api_version_from_config(config_path) + + if api_version_cli_param and api_version_langgraph_json: + raise click.ClickException( + "API version specified in both --api-version CLI flag " + f"({api_version_cli_param!r}) and langgraph.json " + f"({api_version_langgraph_json!r}). Please use only one." + ) + + preferred_api_version = api_version_cli_param or api_version_langgraph_json + + if preferred_api_version and _PATCH_VERSION_RE.match(preferred_api_version): + return preferred_api_version + + version_prefix = preferred_api_version or "" + if version_prefix: + click.secho( + f"Resolving API version matching {version_prefix!r} from Docker Hub...", + fg="cyan", + ) + else: + click.secho( + "Resolving latest API version from Docker Hub...", + fg="cyan", + ) + resolved = _fetch_matching_version(version_prefix) + click.secho(f"Resolved API version: {resolved}", fg="cyan") + return resolved + + +def _read_api_version_from_config(config_path: pathlib.Path) -> str | None: + """Read the `api_version` field from langgraph.json (if present).""" + try: + with open(config_path) as f: + raw_config = json.load(f) + except (OSError, json.JSONDecodeError): + return None + return raw_config.get("api_version") + + +def _fetch_matching_version(version_prefix: str = "") -> str: + """Query Docker Hub for the latest patch version matching *version_prefix*. + + When *version_prefix* is empty, returns the latest published version. + """ + url = f"https://hub.docker.com/v2/repositories/{VERSION_MARKER_REPO}/tags/?page_size=10" + if version_prefix: + url += f"&name={version_prefix}" + 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 API version from {VERSION_MARKER_REPO}: {exc}\n" + "You can specify an exact version with --api-version (e.g. 0.7.67)." + ) from exc + + for tag in data.get("results", []): + name = tag.get("name", "") + if _SEMVER_RE.match(name): + return name + + if version_prefix: + msg = f"Could not find a version matching {version_prefix!r} in {VERSION_MARKER_REPO}." + else: + msg = f"Could not find a published version in {VERSION_MARKER_REPO}." + raise click.ClickException( + f"{msg}\nYou can specify an exact version with --api-version (e.g. 0.7.67)." + ) diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 32edb185ee..f67b7a2a5a 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -23,6 +23,7 @@ import langgraph_cli.config import langgraph_cli.docker from langgraph_cli.analytics import log_command +from langgraph_cli.api_version import resolve_langgraph_api_version from langgraph_cli.config import Config from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT from langgraph_cli.docker import DockerCapabilities @@ -352,6 +353,7 @@ def up( image: str | None, base_image: str | None, ): + api_version = resolve_langgraph_api_version(config, api_version) click.secho("Starting LangGraph API server...", fg="green") click.secho( """For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment. @@ -557,6 +559,7 @@ def build( install_command: str | None, build_command: str | None, ): + api_version = resolve_langgraph_api_version(config, api_version) if install_command and langgraph_cli.config.has_disallowed_build_command_content( install_command ): @@ -689,6 +692,7 @@ def deploy( no_wait: bool, docker_build_args: Sequence[str], ): + deploy_api_version = resolve_langgraph_api_version(config, api_version) click.secho( "Note: 'langgraph deploy' is in beta. Expect frequent updates and improvements.", fg="yellow", @@ -721,7 +725,6 @@ def deploy( "node_version" ) deploy_engine_runtime_mode = "distributed" if is_python else "combined_queue_server" - deploy_api_version = api_version or config_json.get("api_version") # Use buildx to cross-compile for amd64 when running on a non-x86_64 host # (e.g. Apple Silicon). On amd64 hosts, plain docker build is sufficient. @@ -1180,6 +1183,7 @@ def dockerfile( api_version: str | None = None, engine_runtime_mode: str = "combined_queue_worker", ) -> None: + api_version = resolve_langgraph_api_version(config, api_version) save_path = pathlib.Path(save_path).absolute() secho(f"🔍 Validating configuration at path: {config}", fg="yellow") config_json = langgraph_cli.config.validate_config_file(config) @@ -1529,16 +1533,6 @@ def prepare( 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( diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 8ea7dedbf7..32ae63ea27 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -4,7 +4,6 @@ import pathlib import re import textwrap -import urllib.request from collections import Counter from typing import Literal, NamedTuple @@ -1234,37 +1233,6 @@ 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 ````, ````, 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: @@ -1308,6 +1276,11 @@ def docker_tag( else: full_tag = version_distro_tag + # Strip an existing tag from base_image so we don't produce two colons + # (e.g. "langchain/langgraph-server:0.2" → "langchain/langgraph-server"). + if ":" in base_image: + base_image = base_image.rsplit(":", 1)[0] + return f"{base_image}:{full_tag}" diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 48572f4ae2..ee1c807ca3 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -382,9 +382,10 @@ def test_dockerfile_command_with_base_image() -> None: assert save_path.exists() with open(save_path) as f: dockerfile = f.read() - assert re.match("FROM langchain/langgraph-server:0.2-py3.*", dockerfile), ( - "\n".join(dockerfile.splitlines()[:3]) - ) + assert re.match( + r"FROM langchain/langgraph-server:\d+\.\d+\.\d+-py3\..*", + dockerfile, + ), "\n".join(dockerfile.splitlines()[:3]) def test_dockerfile_command_with_docker_compose() -> None: @@ -856,7 +857,10 @@ def test_dockerfile_command_distributed_mode() -> None: assert save_path.exists() with open(save_path) as f: dockerfile = f.read() - assert "FROM langchain/langgraph-executor:3.11" in dockerfile + assert re.search( + r"FROM langchain/langgraph-executor:\d+\.\d+\.\d+-py3\.11", + dockerfile, + ), dockerfile.splitlines()[0] def test_dockerfile_command_combined_mode() -> None: @@ -889,7 +893,10 @@ def test_dockerfile_command_combined_mode() -> None: assert save_path.exists() with open(save_path) as f: dockerfile = f.read() - assert "FROM langchain/langgraph-api:3.11" in dockerfile + assert re.search( + r"FROM langchain/langgraph-api:\d+\.\d+\.\d+-py3\.11", + dockerfile, + ), dockerfile.splitlines()[0] def test_dockerfile_command_distributed_with_explicit_base_image() -> None: @@ -924,7 +931,10 @@ def test_dockerfile_command_distributed_with_explicit_base_image() -> None: assert save_path.exists() with open(save_path) as f: dockerfile = f.read() - assert "FROM my-custom-executor:latest" in dockerfile + assert re.search( + r"FROM my-custom-executor:\d+\.\d+\.\d+-py3\.11", + dockerfile, + ), dockerfile.splitlines()[0] def test_prepare_args_and_stdin_distributed_mode() -> None: diff --git a/libs/cli/tests/unit_tests/test_api_version.py b/libs/cli/tests/unit_tests/test_api_version.py new file mode 100644 index 0000000000..3d5d584666 --- /dev/null +++ b/libs/cli/tests/unit_tests/test_api_version.py @@ -0,0 +1,156 @@ +import io +import json +import pathlib +import urllib.error +import urllib.request + +import click +import pytest + +from langgraph_cli.api_version import ( + _fetch_matching_version, + resolve_langgraph_api_version, +) + + +@pytest.fixture() +def config_dir(tmp_path: pathlib.Path) -> pathlib.Path: + return tmp_path + + +def _write_config( + config_dir: pathlib.Path, api_version: str | None = None +) -> pathlib.Path: + cfg: dict = {"dependencies": ["."], "graphs": {"agent": "agent.py:graph"}} + if api_version is not None: + cfg["api_version"] = api_version + path = config_dir / "langgraph.json" + path.write_text(json.dumps(cfg)) + return path + + +class TestResolveLanggraphApiVersion: + def test_exact_patch_from_cli(self, config_dir: pathlib.Path) -> None: + path = _write_config(config_dir) + assert resolve_langgraph_api_version(path, "0.7.67") == "0.7.67" + + def test_exact_patch_from_json(self, config_dir: pathlib.Path) -> None: + path = _write_config(config_dir, api_version="0.7.67") + assert resolve_langgraph_api_version(path, None) == "0.7.67" + + def test_neither_source_fetches_latest( + self, config_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + path = _write_config(config_dir) + + fake_body = json.dumps( + {"results": [{"name": "latest"}, {"name": "0.9.2"}]} + ).encode() + + def mock_urlopen(url, *, timeout=None): + assert "name=" not in url + return io.BytesIO(fake_body) + + monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen) + assert resolve_langgraph_api_version(path, None) == "0.9.2" + + def test_both_sources_raises(self, config_dir: pathlib.Path) -> None: + path = _write_config(config_dir, api_version="0.7.67") + with pytest.raises(click.ClickException, match="both"): + resolve_langgraph_api_version(path, "0.8.0") + + def test_partial_version_resolves_from_dockerhub( + self, config_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + path = _write_config(config_dir, api_version="0.7") + + fake_body = json.dumps( + {"results": [{"name": "latest"}, {"name": "0.7.67"}]} + ).encode() + + def mock_urlopen(url, *, timeout=None): + assert "name=0.7" in url + return io.BytesIO(fake_body) + + monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen) + assert resolve_langgraph_api_version(path, None) == "0.7.67" + + def test_partial_cli_version_resolves_from_dockerhub( + self, config_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + path = _write_config(config_dir) + + fake_body = json.dumps( + {"results": [{"name": "0.8.1"}, {"name": "0.8.0"}]} + ).encode() + + def mock_urlopen(url, *, timeout=None): + assert "name=0.8" in url + return io.BytesIO(fake_body) + + monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen) + assert resolve_langgraph_api_version(path, "0.8") == "0.8.1" + + def test_missing_config_file_fetches_latest( + self, config_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + path = config_dir / "nonexistent.json" + + fake_body = json.dumps({"results": [{"name": "0.9.2"}]}).encode() + + def mock_urlopen(url, *, timeout=None): + return io.BytesIO(fake_body) + + monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen) + assert resolve_langgraph_api_version(path, None) == "0.9.2" + + def test_missing_config_file_with_cli_version( + self, config_dir: pathlib.Path + ) -> None: + path = config_dir / "nonexistent.json" + assert resolve_langgraph_api_version(path, "0.7.67") == "0.7.67" + + +class TestFetchMatchingVersion: + def test_empty_prefix_returns_latest(self, monkeypatch: pytest.MonkeyPatch) -> None: + fake_body = json.dumps( + {"results": [{"name": "latest"}, {"name": "0.9.2"}, {"name": "abc123"}]} + ).encode() + + def mock_urlopen(url, *, timeout=None): + assert "name=" not in url + return io.BytesIO(fake_body) + + monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen) + assert _fetch_matching_version() == "0.9.2" + + def test_returns_first_semver(self, monkeypatch: pytest.MonkeyPatch) -> None: + 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_matching_version("0.7") == "0.7.67" + + def test_no_semver_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + 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 version"): + _fetch_matching_version("0.7") + + def test_network_error_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + 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_matching_version("0.7") diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 92b8cfe135..66810f6320 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -15,7 +15,6 @@ config_to_docker, default_base_image, docker_tag, - fetch_latest_api_version, has_disallowed_build_command_content, validate_config, validate_config_file, @@ -1892,51 +1891,6 @@ 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"} diff --git a/libs/cli/tests/unit_tests/test_host_backend.py b/libs/cli/tests/unit_tests/test_host_backend.py index 0a08a46680..810ce82b18 100644 --- a/libs/cli/tests/unit_tests/test_host_backend.py +++ b/libs/cli/tests/unit_tests/test_host_backend.py @@ -169,9 +169,7 @@ def handler(req: httpx.Request) -> httpx.Response: headers={"X-Api-Key": "test-key", "Accept": "application/json"}, timeout=30, ) - result = c.update_deployment( - "dep-1", "img:v1", engine_runtime_mode="distributed" - ) + result = c.update_deployment("dep-1", "img:v1", engine_runtime_mode="distributed") assert result == {"id": "dep-1"} From 28cb872ca45fbceeeb8368b42bbebbc870698402 Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Tue, 10 Mar 2026 10:33:08 -0700 Subject: [PATCH 5/6] rm --- libs/cli/langgraph_cli/config.py | 17 +++++------------ libs/cli/tests/unit_tests/test_config.py | 12 ------------ 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 32ae63ea27..3dcca9840b 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -1269,6 +1269,11 @@ def docker_tag( version_distro_tag = f"{version}{distro_tag}" # Prepend API version if provided + # Strip an existing tag from base_image so we don't produce two colons + # (e.g. "langchain/langgraph-server:0.2" → "langchain/langgraph-server"). + if ":" in base_image: + base_image = base_image.rsplit(":", 1)[0] + if api_version: full_tag = f"{api_version}-{language}{version_distro_tag}" elif "/langgraph-server" in base_image and version_distro_tag not in base_image: @@ -1276,11 +1281,6 @@ def docker_tag( else: full_tag = version_distro_tag - # Strip an existing tag from base_image so we don't produce two colons - # (e.g. "langchain/langgraph-server:0.2" → "langchain/langgraph-server"). - if ":" in base_image: - base_image = base_image.rsplit(":", 1)[0] - return f"{base_image}:{full_tag}" @@ -1427,13 +1427,6 @@ 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:{api_version} diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 66810f6320..cf36ee6a75 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -1904,18 +1904,6 @@ def test_config_to_compose_distributed_orchestrator_uses_api_version(): 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"} From 58c23b2d8301bbef33853c6bd4eb68685a9ab8d5 Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Tue, 10 Mar 2026 11:21:13 -0700 Subject: [PATCH 6/6] done --- libs/cli/langgraph_cli/cli.py | 39 ++++---- libs/cli/langgraph_cli/engine_runtime_mode.py | 69 +++++++++++++ libs/cli/tests/unit_tests/cli/test_cli.py | 6 ++ .../unit_tests/test_engine_runtime_mode.py | 99 +++++++++++++++++++ 4 files changed, 196 insertions(+), 17 deletions(-) create mode 100644 libs/cli/langgraph_cli/engine_runtime_mode.py create mode 100644 libs/cli/tests/unit_tests/test_engine_runtime_mode.py diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index f67b7a2a5a..1d7d63fe36 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -27,6 +27,7 @@ from langgraph_cli.config import Config from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT from langgraph_cli.docker import DockerCapabilities +from langgraph_cli.engine_runtime_mode import resolve_engine_runtime_mode from langgraph_cli.exec import Runner, subp_exec from langgraph_cli.host_backend import HostBackendClient, HostBackendError from langgraph_cli.progress import Progress @@ -292,8 +293,8 @@ def _docker_config_for_token(registry_host: str, token: str): OPT_ENGINE_RUNTIME_MODE = click.option( "--engine-runtime-mode", type=click.Choice(["combined_queue_worker", "distributed"]), - default="combined_queue_worker", - help="Runtime mode. 'distributed' uses separate executor and orchestrator containers.", + default=None, + help="Runtime mode. 'distributed' uses separate executor and orchestrator containers. Defaults to distributed.", ) @@ -349,11 +350,14 @@ def up( debugger_base_url: str | None, postgres_uri: str | None, api_version: str | None, - engine_runtime_mode: str, + engine_runtime_mode: str | None, image: str | None, base_image: str | None, ): api_version = resolve_langgraph_api_version(config, api_version) + engine_runtime_mode = resolve_engine_runtime_mode( + config, api_version, engine_runtime_mode + ) click.secho("Starting LangGraph API server...", fg="green") click.secho( """For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment. @@ -553,13 +557,16 @@ def build( docker_build_args: Sequence[str], base_image: str | None, api_version: str | None, - engine_runtime_mode: str, + engine_runtime_mode: str | None, pull: bool, tag: str, install_command: str | None, build_command: str | None, ): api_version = resolve_langgraph_api_version(config, api_version) + engine_runtime_mode = resolve_engine_runtime_mode( + config, api_version, engine_runtime_mode + ) if install_command and langgraph_cli.config.has_disallowed_build_command_content( install_command ): @@ -692,7 +699,8 @@ def deploy( no_wait: bool, docker_build_args: Sequence[str], ): - deploy_api_version = resolve_langgraph_api_version(config, api_version) + api_version = resolve_langgraph_api_version(config, api_version) + engine_runtime_mode = resolve_engine_runtime_mode(config, api_version, None) click.secho( "Note: 'langgraph deploy' is in beta. Expect frequent updates and improvements.", fg="yellow", @@ -720,12 +728,6 @@ def deploy( secrets = _secrets_from_env(env_vars) - # Determine language and runtime mode from config for host backend - is_python = bool(config_json.get("python_version")) or not config_json.get( - "node_version" - ) - deploy_engine_runtime_mode = "distributed" if is_python else "combined_queue_server" - # Use buildx to cross-compile for amd64 when running on a non-x86_64 host # (e.g. Apple Silicon). On amd64 hosts, plain docker build is sufficient. needs_buildx = platform.machine() != "x86_64" @@ -875,10 +877,10 @@ def log_step(message: str) -> None: "source_config": {"deployment_type": deployment_type}, "source_revision_config": {}, "secrets": secrets, - "engine_runtime_mode": deploy_engine_runtime_mode, + "engine_runtime_mode": engine_runtime_mode, } - if deploy_api_version: - payload["deployed_api_version"] = deploy_api_version + if api_version: + payload["deployed_api_version"] = api_version created = client.create_deployment(payload) created_id = created.get("id") if isinstance(created, dict) else None if not isinstance(created_id, str) or not created_id: @@ -991,8 +993,8 @@ def log_step(message: str) -> None: deployment_id, remote_image, secrets=secrets, - engine_runtime_mode=deploy_engine_runtime_mode, - deployed_api_version=deploy_api_version, + engine_runtime_mode=engine_runtime_mode, + deployed_api_version=api_version, ) tenant_id = updated.get("tenant_id") if isinstance(updated, dict) else None if tenant_id: @@ -1181,9 +1183,12 @@ def dockerfile( add_docker_compose: bool, base_image: str | None = None, api_version: str | None = None, - engine_runtime_mode: str = "combined_queue_worker", + engine_runtime_mode: str | None = None, ) -> None: api_version = resolve_langgraph_api_version(config, api_version) + engine_runtime_mode = resolve_engine_runtime_mode( + config, api_version, engine_runtime_mode + ) save_path = pathlib.Path(save_path).absolute() secho(f"🔍 Validating configuration at path: {config}", fg="yellow") config_json = langgraph_cli.config.validate_config_file(config) diff --git a/libs/cli/langgraph_cli/engine_runtime_mode.py b/libs/cli/langgraph_cli/engine_runtime_mode.py new file mode 100644 index 0000000000..55cfe28f61 --- /dev/null +++ b/libs/cli/langgraph_cli/engine_runtime_mode.py @@ -0,0 +1,69 @@ +"""Resolve the LangGraph engine runtime mode.""" + +import json +import pathlib + +import click + +_DISTRIBUTED_MIN_VERSION = (0, 7, 68) + + +def resolve_engine_runtime_mode( + config_path: pathlib.Path, + api_version: str, + engine_runtime_mode_cli_param: str | None, +) -> str: + """Resolve the engine runtime mode. + + *api_version* must already be resolved to a patch-level semver string. + + Returns ``"distributed"`` or ``"combined_queue_worker"``. + + Raises `click.ClickException` when distributed mode is requested but + not supported (JavaScript project or api_version <= 0.7.67). + """ + requires_combined = _requires_combined(config_path, api_version) + + if engine_runtime_mode_cli_param == "distributed": + if requires_combined: + reasons = _constraint_reasons(config_path, api_version) + raise click.ClickException( + f"Distributed runtime is not supported for {' and '.join(reasons)}." + ) + return "distributed" + + if engine_runtime_mode_cli_param == "combined_queue_worker": + return "combined_queue_worker" + + # No explicit choice → default to distributed + return "distributed" + + +def _requires_combined(config_path: pathlib.Path, api_version: str) -> bool: + return _is_javascript_project(config_path) or _version_too_old(api_version) + + +def _version_too_old(api_version: str) -> bool: + try: + parts = tuple(int(x) for x in api_version.split(".")) + except (ValueError, AttributeError): + return True + return parts < _DISTRIBUTED_MIN_VERSION + + +def _is_javascript_project(config_path: pathlib.Path) -> bool: + try: + with open(config_path) as f: + cfg = json.load(f) + except (OSError, json.JSONDecodeError): + return False + return bool(cfg.get("node_version")) and not cfg.get("python_version") + + +def _constraint_reasons(config_path: pathlib.Path, api_version: str) -> list[str]: + reasons: list[str] = [] + if _is_javascript_project(config_path): + reasons.append("JavaScript projects") + if _version_too_old(api_version): + reasons.append(f"API version {api_version} (<= 0.7.67)") + return reasons diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index ee1c807ca3..245dddaa61 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -568,6 +568,8 @@ def test_build_generate_proper_build_context(): "test-image", "--config", str(temp_dir / "config.json"), + "--engine-runtime-mode", + "combined_queue_worker", ], catch_exceptions=True, ) @@ -603,6 +605,8 @@ def test_dockerfile_command_with_api_version() -> None: str(temp_dir / "config.json"), "--api-version", "0.2.74", + "--engine-runtime-mode", + "combined_queue_worker", ], ) @@ -719,6 +723,8 @@ def test_build_command_with_api_version() -> None: str(temp_dir / "config.json"), "--api-version", "0.2.74", + "--engine-runtime-mode", + "combined_queue_worker", "--no-pull", # Avoid pulling non-existent images ], catch_exceptions=True, diff --git a/libs/cli/tests/unit_tests/test_engine_runtime_mode.py b/libs/cli/tests/unit_tests/test_engine_runtime_mode.py new file mode 100644 index 0000000000..a225f47b36 --- /dev/null +++ b/libs/cli/tests/unit_tests/test_engine_runtime_mode.py @@ -0,0 +1,99 @@ +import json +import pathlib + +import click +import pytest + +from langgraph_cli.engine_runtime_mode import resolve_engine_runtime_mode + + +def _write_config( + tmp_path: pathlib.Path, + *, + python_version: str | None = "3.11", + node_version: str | None = None, +) -> pathlib.Path: + cfg: dict = {"dependencies": ["."], "graphs": {"agent": "agent.py:graph"}} + if python_version is not None: + cfg["python_version"] = python_version + if node_version is not None: + cfg["node_version"] = node_version + path = tmp_path / "langgraph.json" + path.write_text(json.dumps(cfg)) + return path + + +class TestResolveEngineRuntimeMode: + # -- cli_param == "distributed" ------------------------------------------- + + def test_distributed_explicit_new_version(self, tmp_path: pathlib.Path) -> None: + path = _write_config(tmp_path) + assert ( + resolve_engine_runtime_mode(path, "0.7.68", "distributed") == "distributed" + ) + + def test_distributed_explicit_old_version_raises( + self, tmp_path: pathlib.Path + ) -> None: + path = _write_config(tmp_path) + with pytest.raises(click.ClickException, match="0.7.67"): + resolve_engine_runtime_mode(path, "0.7.67", "distributed") + + def test_distributed_explicit_js_raises(self, tmp_path: pathlib.Path) -> None: + path = _write_config(tmp_path, python_version=None, node_version="20") + with pytest.raises(click.ClickException, match="JavaScript"): + resolve_engine_runtime_mode(path, "0.8.0", "distributed") + + def test_distributed_explicit_js_and_old_version_raises( + self, tmp_path: pathlib.Path + ) -> None: + path = _write_config(tmp_path, python_version=None, node_version="20") + with pytest.raises(click.ClickException, match="JavaScript.*0.7.60"): + resolve_engine_runtime_mode(path, "0.7.60", "distributed") + + # -- cli_param == "combined_queue_worker" ---------------------------------- + + def test_combined_explicit(self, tmp_path: pathlib.Path) -> None: + path = _write_config(tmp_path) + assert ( + resolve_engine_runtime_mode(path, "0.8.0", "combined_queue_worker") + == "combined_queue_worker" + ) + + def test_combined_explicit_old_version(self, tmp_path: pathlib.Path) -> None: + path = _write_config(tmp_path) + assert ( + resolve_engine_runtime_mode(path, "0.7.67", "combined_queue_worker") + == "combined_queue_worker" + ) + + # -- cli_param is None (default) ------------------------------------------- + + def test_default_is_distributed(self, tmp_path: pathlib.Path) -> None: + path = _write_config(tmp_path) + assert resolve_engine_runtime_mode(path, "0.8.0", None) == "distributed" + + def test_default_old_version(self, tmp_path: pathlib.Path) -> None: + path = _write_config(tmp_path) + assert resolve_engine_runtime_mode(path, "0.7.67", None) == "distributed" + + def test_default_js(self, tmp_path: pathlib.Path) -> None: + path = _write_config(tmp_path, python_version=None, node_version="20") + assert resolve_engine_runtime_mode(path, "0.8.0", None) == "distributed" + + # -- edge: version boundary ------------------------------------------------ + + def test_version_boundary_0_7_67_blocks_distributed( + self, tmp_path: pathlib.Path + ) -> None: + path = _write_config(tmp_path) + with pytest.raises(click.ClickException): + resolve_engine_runtime_mode(path, "0.7.67", "distributed") + + def test_version_boundary_0_7_68_allows_distributed( + self, tmp_path: pathlib.Path + ) -> None: + path = _write_config(tmp_path) + assert ( + resolve_engine_runtime_mode(path, "0.7.68", "distributed") == "distributed" + )