mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-20 08:37:59 +02:00
refactor(cli): resolve control plane endpoints in one value object
This commit is contained in:
@@ -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 <scheme>://<host>/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 <langsmith_endpoint>/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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user