mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-20 08:37:59 +02:00
fix(cli): support LANGSMITH_WORKSPACE_ID for deployments
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user