From b11d6572ffd5bc96dfaa7dfc2383640337fad47a Mon Sep 17 00:00:00 2001 From: Hugo Durand Date: Fri, 18 Sep 2026 13:36:16 -0400 Subject: [PATCH] refactor(cli): resolve control plane endpoints in one value object --- libs/cli/langgraph_cli/deploy.py | 77 +++-------- libs/cli/langgraph_cli/host_backend.py | 73 +++++++++++ .../tests/unit_tests/test_deploy_helpers.py | 118 ++--------------- .../cli/tests/unit_tests/test_host_backend.py | 120 +++++++++++++++++- 4 files changed, 222 insertions(+), 166 deletions(-) diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index f73180419..d2107e3ca 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -23,7 +23,11 @@ from langgraph_cli.constants import DEFAULT_CONFIG from langgraph_cli.dependency_tracking import find_tracked_packages from langgraph_cli.docker import build_docker_image, can_build_locally from langgraph_cli.exec import Runner, subp_exec -from langgraph_cli.host_backend import HostBackendClient, HostBackendError +from langgraph_cli.host_backend import ( + ControlPlaneEndpoints, + HostBackendClient, + HostBackendError, +) from langgraph_cli.progress import Progress from langgraph_cli.util import warn_non_wolfi_distro @@ -658,45 +662,19 @@ def _create_deployment( return created_id, step + 1 -def _smith_dashboard_base_url(host_url: str | None) -> str: - """Derive the LangSmith dashboard base URL from the API host URL.""" - from urllib.parse import urlparse - - if not host_url: - return "https://smith.langchain.com" - parsed = urlparse(host_url) - hostname = parsed.hostname or "" - # Self-hosted: host_url is :///api-host — return just the root - path = parsed.path.rstrip("/") - if path == "/api-host" or path.endswith("/api-host"): - return f"{parsed.scheme}://{parsed.netloc}" - - if hostname in ("localhost", "127.0.0.1"): - return host_url.rstrip("/") - - api_host_suffix = "api.host.langchain.com" - if hostname == api_host_suffix: - return "https://smith.langchain.com" - if hostname.endswith(f".{api_host_suffix}"): - prefix = hostname[: -(len(api_host_suffix) + 1)] - return f"https://{prefix}.smith.langchain.com" - - return "https://smith.langchain.com" - - def _get_deployment_status_url( - updated: object, deployment_id: str, host_url: str | None = None + updated: object, deployment_id: str, host_url: str ) -> str | None: """Compute the LangSmith dashboard URL for a deployment, if possible.""" tenant_id = updated.get("tenant_id") if isinstance(updated, dict) else None if not tenant_id: return None - base = _smith_dashboard_base_url(host_url) + base = ControlPlaneEndpoints.from_control_plane_url(host_url).dashboard_url return f"{base}/o/{tenant_id}/host/deployments/{deployment_id}" def _emit_deployment_status_url( - updated: object, deployment_id: str, host_url: str | None = None + updated: object, deployment_id: str, host_url: str ) -> str | None: """Emit the deployment status URL and return it.""" url = _get_deployment_status_url(updated, deployment_id, host_url) @@ -1352,30 +1330,13 @@ def _create_host_backend_client( tenant_id = env_vars.get("LANGSMITH_TENANT_ID") or os.environ.get( "LANGSMITH_TENANT_ID" ) - # If no explicit host URL was provided, check LANGSMITH_ENDPOINT as a - # fallback so self-hosted customers don't need to know about LANGGRAPH_HOST_URL. - # Self-hosted control plane always lives at /api-host. - _cloud_default = "https://api.host.langchain.com" - _cloud_endpoints = { - "https://api.smith.langchain.com", - "https://api.langchain.com", - } - resolved_host = host_url - if not resolved_host or resolved_host == _cloud_default: - langsmith_endpoint = env_vars.get("LANGSMITH_ENDPOINT") or os.environ.get( - "LANGSMITH_ENDPOINT" - ) - if ( - langsmith_endpoint - and langsmith_endpoint.rstrip("/") not in _cloud_endpoints - ): - from urllib.parse import urlparse as _urlparse - - _p = _urlparse(langsmith_endpoint) - resolved_host = f"{_p.scheme}://{_p.netloc}/api-host" - else: - resolved_host = _cloud_default - return HostBackendClient(resolved_host, resolved_api_key, tenant_id=tenant_id) + langsmith_endpoint = env_vars.get("LANGSMITH_ENDPOINT") or os.environ.get( + "LANGSMITH_ENDPOINT" + ) + endpoints = ControlPlaneEndpoints.resolve(host_url, langsmith_endpoint) + return HostBackendClient( + endpoints.control_plane_url, resolved_api_key, tenant_id=tenant_id + ) def _call_host_backend_with_optional_tenant( @@ -1418,7 +1379,9 @@ def _call_host_backend_with_optional_tenant( prompted_for_tenant = True continue if err.status_code == 403 and "not enabled" in err.message.lower(): - smith_base = _smith_dashboard_base_url(client.base_url) + smith_base = ControlPlaneEndpoints.from_control_plane_url( + client.base_url + ).dashboard_url raise HostBackendError( "LangSmith Deployment is not enabled for this organization. " f"Enable it at {smith_base}/host/deployments" @@ -1454,7 +1417,7 @@ OPT_HOST_DEPLOYMENT_NAME = click.option( OPT_HOST_URL = click.option( "--host-url", envvar="LANGGRAPH_HOST_URL", - default="https://api.host.langchain.com", + default=None, hidden=True, ) @@ -2146,7 +2109,7 @@ def deploy_logs( start_time: str | None, end_time: str | None, follow: bool, - host_url: str, + host_url: str | None, ): env_vars = _parse_env_from_config({}, pathlib.Path.cwd() / DEFAULT_CONFIG) client = _create_host_backend_client(host_url, api_key, env_vars=env_vars) diff --git a/libs/cli/langgraph_cli/host_backend.py b/libs/cli/langgraph_cli/host_backend.py index 17e3a2822..af0441c4a 100644 --- a/libs/cli/langgraph_cli/host_backend.py +++ b/libs/cli/langgraph_cli/host_backend.py @@ -2,11 +2,84 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any +from urllib.parse import urlparse import click import httpx +CLOUD_CONTROL_PLANE_URL = "https://api.host.langchain.com" +CLOUD_DASHBOARD_URL = "https://smith.langchain.com" +CLOUD_DOMAIN = "langchain.com" +CLOUD_API_HOST = "api.smith.langchain.com" +CLOUD_CONTROL_PLANE_HOST = "api.host.langchain.com" +CLOUD_DASHBOARD_HOST = "smith.langchain.com" +CONTROL_PLANE_PATH = "/api-host" +LANGSMITH_API_PATHS = ("/api/v1", "/api") +LOCAL_HOSTNAMES = ("localhost", "127.0.0.1") + + +@dataclass(frozen=True, slots=True) +class ControlPlaneEndpoints: + control_plane_url: str + dashboard_url: str + + @classmethod + def resolve( + cls, host_url: str | None, langsmith_endpoint: str | None + ) -> ControlPlaneEndpoints: + if host_url: + return cls.from_control_plane_url(host_url) + if langsmith_endpoint: + return cls.from_langsmith_endpoint(langsmith_endpoint) + return cls(CLOUD_CONTROL_PLANE_URL, CLOUD_DASHBOARD_URL) + + @classmethod + def from_control_plane_url(cls, url: str) -> ControlPlaneEndpoints: + control_plane_url = url.rstrip("/") + hostname = urlparse(control_plane_url).hostname or "" + if control_plane_url.endswith(CONTROL_PLANE_PATH): + return cls(control_plane_url, control_plane_url[: -len(CONTROL_PLANE_PATH)]) + if hostname in LOCAL_HOSTNAMES: + return cls(control_plane_url, control_plane_url) + return cls(control_plane_url, _cloud_dashboard_for(hostname)) + + @classmethod + def from_langsmith_endpoint(cls, endpoint: str) -> ControlPlaneEndpoints: + parsed = urlparse(endpoint.rstrip("/")) + hostname = parsed.hostname or "" + if _is_cloud_host(hostname): + return cls.from_control_plane_url( + f"https://{_cloud_control_plane_host_for(hostname)}" + ) + root = f"{parsed.scheme}://{parsed.netloc}{_without_api_path(parsed.path)}" + return cls(f"{root}{CONTROL_PLANE_PATH}", root) + + +def _is_cloud_host(hostname: str) -> bool: + return hostname == CLOUD_DOMAIN or hostname.endswith(f".{CLOUD_DOMAIN}") + + +def _cloud_control_plane_host_for(langsmith_api_host: str) -> str: + if langsmith_api_host.endswith(CLOUD_API_HOST): + return langsmith_api_host.replace(CLOUD_API_HOST, CLOUD_CONTROL_PLANE_HOST) + return CLOUD_CONTROL_PLANE_HOST + + +def _cloud_dashboard_for(control_plane_host: str) -> str: + if control_plane_host.endswith(f".{CLOUD_CONTROL_PLANE_HOST}"): + region = control_plane_host[: -len(CLOUD_CONTROL_PLANE_HOST) - 1] + return f"https://{region}.{CLOUD_DASHBOARD_HOST}" + return CLOUD_DASHBOARD_URL + + +def _without_api_path(path: str) -> str: + for api_path in LANGSMITH_API_PATHS: + if path.endswith(api_path): + return path[: -len(api_path)] + return path + class HostBackendError(click.ClickException): """Raised when the host backend returns an error response.""" diff --git a/libs/cli/tests/unit_tests/test_deploy_helpers.py b/libs/cli/tests/unit_tests/test_deploy_helpers.py index f88ed1731..5577265c1 100644 --- a/libs/cli/tests/unit_tests/test_deploy_helpers.py +++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py @@ -21,7 +21,6 @@ from langgraph_cli.deploy import ( _parse_env_from_config, _resolve_env_path, _resolve_pushed_image_digest, - _smith_dashboard_base_url, _validate_prebuilt_image, normalize_image_tag, normalize_name, @@ -538,128 +537,31 @@ class TestCreateHostBackendClientNoInput: assert client is not None -class TestCreateHostBackendClientEndpointFallback: - def test_langsmith_endpoint_env_var_used_as_fallback(self, monkeypatch): - monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test") - monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://smith.example.com/api/v1") - monkeypatch.delenv("LANGGRAPH_HOST_URL", raising=False) - client = _create_host_backend_client(host_url=None, api_key=None, env_vars={}) - assert client.base_url == "https://smith.example.com/api-host" - - def test_langsmith_endpoint_from_env_vars_dict(self, monkeypatch): +class TestCreateHostBackendClientEndpoint: + def test_langsmith_endpoint_from_project_env_selects_self_hosted_control_plane( + self, monkeypatch + ): monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test") monkeypatch.delenv("LANGSMITH_ENDPOINT", raising=False) + client = _create_host_backend_client( host_url=None, api_key=None, env_vars={"LANGSMITH_ENDPOINT": "https://smith.example.com/api/v1"}, ) + assert client.base_url == "https://smith.example.com/api-host" - def test_cloud_langsmith_endpoint_not_used_as_self_hosted(self, monkeypatch): - monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test") - monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com") - client = _create_host_backend_client(host_url=None, api_key=None, env_vars={}) - assert client.base_url == "https://api.host.langchain.com" - - def test_langchain_api_endpoint_not_used_as_self_hosted(self, monkeypatch): - monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test") - monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://api.langchain.com") - client = _create_host_backend_client(host_url=None, api_key=None, env_vars={}) - assert client.base_url == "https://api.host.langchain.com" - - def test_explicit_host_url_takes_precedence_over_langsmith_endpoint( - self, monkeypatch - ): + def test_explicit_host_url_wins_over_langsmith_endpoint(self, monkeypatch): monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test") monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://smith.example.com/api/v1") + client = _create_host_backend_client( - host_url="https://custom.host.com", - api_key=None, - env_vars={}, + host_url="https://custom.host.com", api_key=None, env_vars={} ) + assert client.base_url == "https://custom.host.com" - def test_no_endpoint_falls_back_to_cloud_default(self, monkeypatch): - monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test") - monkeypatch.delenv("LANGSMITH_ENDPOINT", raising=False) - client = _create_host_backend_client(host_url=None, api_key=None, env_vars={}) - assert client.base_url == "https://api.host.langchain.com" - - -class TestSmithDashboardBaseUrl: - def test_none_returns_default(self): - assert _smith_dashboard_base_url(None) == "https://smith.langchain.com" - - def test_empty_returns_default(self): - assert _smith_dashboard_base_url("") == "https://smith.langchain.com" - - def test_prod_host_url(self): - assert ( - _smith_dashboard_base_url("https://api.host.langchain.com") - == "https://smith.langchain.com" - ) - - def test_dev_host_url(self): - assert ( - _smith_dashboard_base_url("https://dev.api.host.langchain.com") - == "https://dev.smith.langchain.com" - ) - - def test_eu_host_url(self): - assert ( - _smith_dashboard_base_url("https://eu.api.host.langchain.com") - == "https://eu.smith.langchain.com" - ) - - def test_staging_host_url(self): - assert ( - _smith_dashboard_base_url("https://staging.api.host.langchain.com") - == "https://staging.smith.langchain.com" - ) - - def test_localhost(self): - assert ( - _smith_dashboard_base_url("http://localhost:8080") - == "http://localhost:8080" - ) - - def test_localhost_trailing_slash(self): - assert ( - _smith_dashboard_base_url("http://localhost:8080/") - == "http://localhost:8080" - ) - - def test_127_0_0_1(self): - assert ( - _smith_dashboard_base_url("http://127.0.0.1:3000") - == "http://127.0.0.1:3000" - ) - - def test_unknown_domain_returns_default(self): - assert ( - _smith_dashboard_base_url("https://custom.example.com") - == "https://smith.langchain.com" - ) - - def test_self_hosted_api_host_suffix(self): - assert ( - _smith_dashboard_base_url("https://smith.example.com/api-host") - == "https://smith.example.com" - ) - - def test_self_hosted_api_host_trailing_slash(self): - assert ( - _smith_dashboard_base_url("https://smith.example.com/api-host/") - == "https://smith.example.com" - ) - - def test_self_hosted_localhost_api_host(self): - assert ( - _smith_dashboard_base_url("http://localhost:8080/api-host") - == "http://localhost:8080" - ) - class TestResolvePushedImageDigest: """Tests for ``_resolve_pushed_image_digest`` — runner is mocked to diff --git a/libs/cli/tests/unit_tests/test_host_backend.py b/libs/cli/tests/unit_tests/test_host_backend.py index c373fbb1b..b737ddba3 100644 --- a/libs/cli/tests/unit_tests/test_host_backend.py +++ b/libs/cli/tests/unit_tests/test_host_backend.py @@ -3,7 +3,11 @@ import json import httpx import pytest -from langgraph_cli.host_backend import HostBackendClient, HostBackendError +from langgraph_cli.host_backend import ( + ControlPlaneEndpoints, + HostBackendClient, + HostBackendError, +) @pytest.fixture @@ -485,3 +489,117 @@ def test_injected_transport_receives_requests_under_the_prefixed_base_url(): "url": "https://smith.example.com/api-host/v2/deployments/dep-1/revisions?limit=2", "api_key": "key", } + + +CLOUD = ("https://api.host.langchain.com", "https://smith.langchain.com") + + +@pytest.mark.parametrize( + ("host_url", "langsmith_endpoint", "expected"), + [ + pytest.param(None, None, CLOUD, id="nothing_configured_targets_cloud"), + pytest.param( + None, "https://api.smith.langchain.com", CLOUD, id="cloud_langsmith_api" + ), + pytest.param( + None, + "https://api.smith.langchain.com/api/v1", + CLOUD, + id="cloud_langsmith_api_with_versioned_path", + ), + pytest.param( + None, "https://api.langchain.com", CLOUD, id="cloud_langchain_api_alias" + ), + pytest.param( + None, + "https://eu.api.smith.langchain.com", + ("https://eu.api.host.langchain.com", "https://eu.smith.langchain.com"), + id="eu_cloud_maps_to_eu_control_plane", + ), + pytest.param( + None, + "https://dev.api.smith.langchain.com", + ("https://dev.api.host.langchain.com", "https://dev.smith.langchain.com"), + id="dev_cloud_maps_to_dev_control_plane", + ), + pytest.param( + None, + "https://aks.smith.langchain.dev/api", + ( + "https://aks.smith.langchain.dev/api-host", + "https://aks.smith.langchain.dev", + ), + id="self_hosted_api_path_becomes_api_host", + ), + pytest.param( + None, + "https://smith.example.com/api/v1", + ("https://smith.example.com/api-host", "https://smith.example.com"), + id="self_hosted_versioned_api_path_becomes_api_host", + ), + pytest.param( + None, + "https://smith.example.com", + ("https://smith.example.com/api-host", "https://smith.example.com"), + id="self_hosted_origin_gets_api_host_appended", + ), + pytest.param( + None, + "https://corp.example.com/langsmith/api/v1", + ( + "https://corp.example.com/langsmith/api-host", + "https://corp.example.com/langsmith", + ), + id="self_hosted_path_prefix_is_kept", + ), + pytest.param( + "https://custom.host.example", + "https://aks.smith.langchain.dev/api", + ("https://custom.host.example", "https://smith.langchain.com"), + id="explicit_host_url_beats_langsmith_endpoint", + ), + pytest.param( + "https://api.host.langchain.com", + "https://aks.smith.langchain.dev/api", + CLOUD, + id="explicit_cloud_host_url_beats_self_hosted_endpoint", + ), + pytest.param( + "https://smith.example.com/api-host/", + None, + ("https://smith.example.com/api-host", "https://smith.example.com"), + id="explicit_api_host_url_derives_dashboard_root", + ), + pytest.param( + "https://corp.example.com/langsmith/api-host", + None, + ( + "https://corp.example.com/langsmith/api-host", + "https://corp.example.com/langsmith", + ), + id="explicit_api_host_url_keeps_path_prefix_in_dashboard", + ), + pytest.param( + "http://localhost:8080", + None, + ("http://localhost:8080", "http://localhost:8080"), + id="localhost_dashboard_is_the_same_origin", + ), + pytest.param( + "http://localhost:8080/api-host", + None, + ("http://localhost:8080/api-host", "http://localhost:8080"), + id="localhost_api_host_dashboard_is_the_origin", + ), + pytest.param( + "https://eu.api.host.langchain.com", + None, + ("https://eu.api.host.langchain.com", "https://eu.smith.langchain.com"), + id="regional_control_plane_maps_to_regional_dashboard", + ), + ], +) +def test_control_plane_endpoints_resolve(host_url, langsmith_endpoint, expected): + endpoints = ControlPlaneEndpoints.resolve(host_url, langsmith_endpoint) + + assert (endpoints.control_plane_url, endpoints.dashboard_url) == expected