From c416b6561c59ae41a897b18093d0d688a2b87966 Mon Sep 17 00:00:00 2001 From: Rafid Saad Date: Sun, 13 Sep 2026 13:49:01 -0700 Subject: [PATCH] fix(cli): support LANGSMITH_WORKSPACE_ID for deployments --- libs/cli/README.md | 21 ++++ libs/cli/langgraph_cli/deploy.py | 16 ++- .../tests/unit_tests/test_deploy_helpers.py | 98 ++++++++++++++++++- 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/libs/cli/README.md b/libs/cli/README.md index cac7808bb..f26e6795b 100644 --- a/libs/cli/README.md +++ b/libs/cli/README.md @@ -86,6 +86,27 @@ langgraph dockerfile SAVE_PATH [OPTIONS] -c, --config FILE Config file path ``` +### `langgraph deploy` + +Build and deploy your app to LangSmith Deployment: + +```bash +langgraph deploy +``` + +Set `LANGSMITH_WORKSPACE_ID` to select a workspace. The legacy name +`LANGSMITH_TENANT_ID` remains supported. Resolution uses the first nonempty value +in this order: + +1. `LANGSMITH_WORKSPACE_ID` in the configured env file/inline env, or default `.env`. +2. `LANGSMITH_WORKSPACE_ID` in the shell environment. +3. `LANGSMITH_TENANT_ID` in the configured env file/inline env, or default `.env`. +4. `LANGSMITH_TENANT_ID` in the shell environment. + +This applies to `deploy` and its subcommands. The selected workspace is sent in +the existing `X-Tenant-ID` request header; neither name is uploaded as a deployment +secret. + ## Configuration The CLI uses a `langgraph.json` configuration file with these key settings: diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index a6e8403e6..6a2b43ad7 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -63,6 +63,8 @@ RESERVED_ENV_VARS = frozenset( "DD_TRACE_REDIS_ENABLED", "LANGSMITH_DEPLOYMENT_NAME", "LANGGRAPH_CLOUD_LICENSE_KEY", + # CLI workspace selection must not override the deployed runtime. + "LANGSMITH_WORKSPACE_ID", # ALLOWED_SELF_HOSTED_ENV_VARS (rejected for non-self-hosted) "LANGSMITH_API_KEY", "LANGSMITH_ENDPOINT", @@ -1258,8 +1260,13 @@ def _create_host_backend_client( fg="yellow", ) resolved_api_key = click.prompt("Enter LangSmith API key", hide_input=True) - tenant_id = env_vars.get("LANGSMITH_TENANT_ID") or os.environ.get( - "LANGSMITH_TENANT_ID" + # Prefer the public workspace name, retaining tenant as a legacy alias. + # As with API keys, config/.env takes precedence over the shell per name. + tenant_id = ( + env_vars.get("LANGSMITH_WORKSPACE_ID") + or os.environ.get("LANGSMITH_WORKSPACE_ID") + or env_vars.get("LANGSMITH_TENANT_ID") + or os.environ.get("LANGSMITH_TENANT_ID") ) return HostBackendClient(host_url, resolved_api_key, tenant_id=tenant_id) @@ -1289,7 +1296,8 @@ def _call_host_backend_with_optional_tenant( if _no_input: raise click.ClickException( "API key is org-scoped and requires a workspace ID. " - "Set LANGSMITH_TENANT_ID in your .env file or " + "Set LANGSMITH_WORKSPACE_ID (or LANGSMITH_TENANT_ID) " + "in your environment or .env file, or " "use a workspace-scoped API key." ) from None click.secho( @@ -1545,6 +1553,8 @@ def _deploy_base_options( "[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" + "Set LANGSMITH_WORKSPACE_ID in your environment or .env file to select " + "a workspace. LANGSMITH_TENANT_ID is supported as a fallback.\n\n" "Run from the root of your LangGraph project (where langgraph.json " "is located). This command also accepts build flags (--base-image, " "--config, --pull, etc.). See 'langgraph build --help' for details." diff --git a/libs/cli/tests/unit_tests/test_deploy_helpers.py b/libs/cli/tests/unit_tests/test_deploy_helpers.py index 5112450f8..f789189de 100644 --- a/libs/cli/tests/unit_tests/test_deploy_helpers.py +++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py @@ -21,6 +21,7 @@ from langgraph_cli.deploy import ( _parse_env_from_config, _resolve_env_path, _resolve_pushed_image_digest, + _secrets_from_env, _smith_dashboard_base_url, _validate_prebuilt_image, normalize_image_tag, @@ -354,7 +355,7 @@ class TestCallHostBackendWithOptionalTenant: client = self._make_client( lambda req: httpx.Response(403, text=requires_workspace) ) - with pytest.raises(click.ClickException, match="workspace"): + with pytest.raises(click.ClickException, match="LANGSMITH_WORKSPACE_ID"): _call_host_backend_with_optional_tenant( client, lambda c: c.list_deployments() ) @@ -540,6 +541,101 @@ class TestCreateHostBackendClientNoInput: assert client is not None +class TestWorkspaceSelection: + @pytest.mark.parametrize( + "configured_workspace,shell_workspace,configured_tenant,shell_tenant,expected", + [ + ("config-workspace", None, None, None, "config-workspace"), + (None, "shell-workspace", None, None, "shell-workspace"), + (None, None, "config-tenant", None, "config-tenant"), + (None, None, None, "shell-tenant", "shell-tenant"), + ( + "config-workspace", + "shell-workspace", + "config-tenant", + "shell-tenant", + "config-workspace", + ), + ( + None, + "shell-workspace", + "config-tenant", + "shell-tenant", + "shell-workspace", + ), + (None, None, "config-tenant", "shell-tenant", "config-tenant"), + ("", "shell-workspace", "config-tenant", None, "shell-workspace"), + ("", "", "config-tenant", "shell-tenant", "config-tenant"), + (None, None, "", "shell-tenant", "shell-tenant"), + (None, None, None, None, None), + ("", "", "", "", None), + ], + ) + def test_request_workspace_header( + self, + monkeypatch, + configured_workspace, + shell_workspace, + configured_tenant, + shell_tenant, + expected, + ): + env_vars = {} + for name, configured, shell in [ + ("LANGSMITH_WORKSPACE_ID", configured_workspace, shell_workspace), + ("LANGSMITH_TENANT_ID", configured_tenant, shell_tenant), + ]: + monkeypatch.delenv(name, raising=False) + if configured is not None: + env_vars[name] = configured + if shell is not None: + monkeypatch.setenv(name, shell) + + def handler(request): + assert request.headers.get("X-Tenant-ID") == expected + return httpx.Response(200, json={"deployments": []}) + + monkeypatch.setattr( + httpx, "HTTPTransport", lambda **kwargs: httpx.MockTransport(handler) + ) + client = _create_host_backend_client( + "https://api.example.com", "test-key", env_vars + ) + try: + assert client.list_deployments() == {"deployments": []} + finally: + client._client.close() + + @pytest.mark.parametrize("name", ["LANGSMITH_WORKSPACE_ID", "LANGSMITH_TENANT_ID"]) + def test_loads_workspace_from_dotenv(self, monkeypatch, tmp_path, name): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("LANGSMITH_WORKSPACE_ID", raising=False) + monkeypatch.delenv("LANGSMITH_TENANT_ID", raising=False) + (tmp_path / ".env").write_text(f"{name}=file-workspace\n") + + def handler(request): + assert request.headers["X-Tenant-ID"] == "file-workspace" + return httpx.Response(200, json={"deployments": []}) + + monkeypatch.setattr( + httpx, "HTTPTransport", lambda **kwargs: httpx.MockTransport(handler) + ) + client = _create_host_backend_client("https://api.example.com", "test-key") + try: + assert client.list_deployments() == {"deployments": []} + finally: + client._client.close() + + def test_workspace_selection_is_not_uploaded_as_secrets(self): + assert _secrets_from_env( + { + "LANGSMITH_WORKSPACE_ID": "workspace-id", + "LANGSMITH_TENANT_ID": "legacy-workspace-id", + "APP_SETTING": "value", + } + ) == [{"name": "APP_SETTING", "value": "value"}] + + class TestSmithDashboardBaseUrl: def test_none_returns_default(self): assert _smith_dashboard_base_url(None) == "https://smith.langchain.com"