diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 797dd0e06..ce6be3b86 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -764,7 +764,7 @@ def _deploy_base_options( @cli.group( cls=DeployGroup, help=( - "[Beta] Build and deploy a LangGraph image to LangSmith Deployments.\n\n" + "[Beta] Build and deploy a LangGraph image to LangSmith Deployment.\n\n" "This command is in beta and under active development. " "Expect frequent updates and improvements.\n\n" "Run from the root of your LangGraph project (where langgraph.json " @@ -1122,7 +1122,7 @@ def _deploy( ) else: click.secho( - " Check status in the LangSmith Deployments dashboard.", + " Check status in the LangSmith Deployment dashboard.", fg="yellow", ) @@ -1165,22 +1165,42 @@ def _call_host_backend_with_optional_tenant( in-place so all subsequent calls through the same instance are tenant-aware. """ - try: - return operation(client) - except HostBackendError as err: - if err.status_code == 403 and "requires workspace specification" in err.message: - click.secho( - "Your API key is org-scoped and requires a workspace ID.", - fg="yellow", - ) - click.secho( - "Find your workspace ID in LangSmith under Settings > Workspaces.", - fg="yellow", - ) - tenant_id = click.prompt("Workspace ID") - client._client.headers["X-Tenant-ID"] = tenant_id + prompted_for_tenant = False + + while True: + try: return operation(client) - raise + except HostBackendError as err: + if ( + not prompted_for_tenant + and err.status_code == 403 + and "requires workspace specification" in err.message + ): + click.secho( + "Your API key is org-scoped and requires a workspace ID.", + fg="yellow", + ) + click.secho( + "Find your workspace ID in LangSmith under Settings > Workspaces.", + fg="yellow", + ) + client._client.headers["X-Tenant-ID"] = click.prompt("Workspace ID") + prompted_for_tenant = True + continue + if err.status_code == 403 and "not enabled" in err.message.lower(): + from urllib.parse import urlparse + + smith_host = "smith.langchain.com" + parsed = urlparse(client._base_url) + if (parsed.hostname or "").startswith("eu."): + smith_host = "eu.smith.langchain.com" + raise HostBackendError( + "LangSmith Deployment is not enabled for this organization. " + f"Enable it at https://{smith_host}/host/deployments" + " (ensure this matches the organization for your API key).", + status_code=403, + ) from None + raise @OPT_HOST_API_KEY diff --git a/libs/cli/tests/unit_tests/test_deploy_helpers.py b/libs/cli/tests/unit_tests/test_deploy_helpers.py index e8287f6a3..7da3cc7cb 100644 --- a/libs/cli/tests/unit_tests/test_deploy_helpers.py +++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py @@ -3,14 +3,17 @@ import json import os import click +import httpx import pytest from langgraph_cli.cli import ( + _call_host_backend_with_optional_tenant, _docker_config_for_token, _normalize_image_name, _normalize_image_tag, _parse_env_from_config, ) +from langgraph_cli.host_backend import HostBackendClient, HostBackendError class TestDockerConfigForToken: @@ -132,3 +135,92 @@ class TestParseEnvFromConfig: assert result["GOOD"] == "value" # EMPTY= gives empty string, not None, so it should be present assert result["EMPTY"] == "" + + +class TestCallHostBackendWithOptionalTenant: + def _make_client(self, handler): + 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, + ) + return c + + def _make_eu_client(self, handler): + c = HostBackendClient("https://eu.api.host.langchain.com", "test-key") + c._client = httpx.Client( + base_url="https://eu.api.host.langchain.com", + transport=httpx.MockTransport(handler), + headers={"X-Api-Key": "test-key", "Accept": "application/json"}, + timeout=30, + ) + return c + + def test_success_passes_through(self): + client = self._make_client(lambda req: httpx.Response(200, json={"ok": True})) + result = _call_host_backend_with_optional_tenant( + client, lambda c: c.list_deployments() + ) + assert result == {"ok": True} + + def test_403_not_enabled_gives_actionable_error(self): + detail = ( + '{"detail":"LangSmith Deployment is not enabled for this organization"}' + ) + client = self._make_client(lambda req: httpx.Response(403, text=detail)) + with pytest.raises(HostBackendError, match="not enabled") as exc_info: + _call_host_backend_with_optional_tenant( + client, lambda c: c.list_deployments() + ) + assert exc_info.value.status_code == 403 + assert "smith.langchain.com" in exc_info.value.message + + def test_403_not_enabled_eu_url(self): + detail = ( + '{"detail":"LangSmith Deployment is not enabled for this organization"}' + ) + client = self._make_eu_client(lambda req: httpx.Response(403, text=detail)) + with pytest.raises(HostBackendError, match="not enabled") as exc_info: + _call_host_backend_with_optional_tenant( + client, lambda c: c.list_deployments() + ) + assert "eu.smith.langchain.com" in exc_info.value.message + + def test_workspace_retry_then_not_enabled_gives_actionable_error(self, monkeypatch): + requires_workspace = '{"detail":"requires workspace specification"}' + not_enabled = ( + '{"detail":"LangSmith Deployment is not enabled for this organization"}' + ) + seen_tenant_ids = [] + + def handler(req): + seen_tenant_ids.append(req.headers.get("X-Tenant-ID")) + if len(seen_tenant_ids) == 1: + return httpx.Response(403, text=requires_workspace) + if len(seen_tenant_ids) == 2: + return httpx.Response(403, text=not_enabled) + raise AssertionError("unexpected extra request") + + monkeypatch.setattr(click, "prompt", lambda _text: "workspace-123") + client = self._make_client(handler) + + with pytest.raises(HostBackendError, match="not enabled") as exc_info: + _call_host_backend_with_optional_tenant( + client, lambda c: c.list_deployments() + ) + + assert exc_info.value.status_code == 403 + assert "smith.langchain.com" in exc_info.value.message + assert seen_tenant_ids == [None, "workspace-123"] + assert client._client.headers["X-Tenant-ID"] == "workspace-123" + + def test_other_403_re_raises_original(self): + client = self._make_client( + lambda req: httpx.Response(403, text='{"detail":"some other error"}') + ) + with pytest.raises(HostBackendError, match="some other error"): + _call_host_backend_with_optional_tenant( + client, lambda c: c.list_deployments() + )