From db40389fbdb43304de0ca108757e11063c12160d Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Tue, 10 Mar 2026 10:03:32 -0700 Subject: [PATCH] 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 578bb69cc..32edb185e 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -13,6 +13,7 @@ import tempfile 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 deploy( 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 deploy( # -- 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 f53055051..40e8f133c 100644 --- a/libs/cli/langgraph_cli/host_backend.py +++ b/libs/cli/langgraph_cli/host_backend.py @@ -82,12 +82,18 @@ class HostBackendClient: 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 f835b1971..48572f4ae 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 3e91d4553..0a08a4668 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}