fix(cli): support LANGSMITH_WORKSPACE_ID for deployments

This commit is contained in:
Rafid Saad
2026-09-13 13:49:01 -07:00
parent e539ac122f
commit c416b6561c
3 changed files with 131 additions and 4 deletions
+21
View File
@@ -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:
+13 -3
View File
@@ -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."
@@ -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"