Compare commits

...
Author SHA1 Message Date
Hari Dhanushkodiandopen-swe[bot] bc1b23ffdc feat(cli): add deploy scale command for compute and Postgres tiers
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-11-03 03:18:13 +00:00
Hari Dhanushkodiandopen-swe[bot] 95cdb86d28 refactor(cli): split scaling into a follow-up PR
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-11-03 03:17:26 +00:00
hari-dhanushkodiandClaude Opus 5.5 0a05f15e21 fix(cli): drop --step-seconds from deploy metrics
Let host-backend derive the time-series resolution from the requested
range (langchain-ai/langchainplus#56507) instead of exposing a flag that
can request ~10k points per series on long ranges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:51:06 -04:00
Hari Dhanushkodiandopen-swe[bot] 2a30615bd7 feat(cli): expose deployment monitoring and resource scaling
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-10-28 19:47:32 +00:00
4 changed files with 287 additions and 0 deletions

No files matched your search

+41
View File
@@ -86,6 +86,47 @@ langgraph dockerfile SAVE_PATH [OPTIONS]
-c, --config FILE Config file path
```
### `langgraph deploy metrics`
Fetch deployment monitoring time series as JSON through the LangSmith control plane.
Use `langgraph deploy list` to find the deployment ID. Authentication uses the same
API key and workspace configuration as the other deployment commands.
```bash
langgraph deploy metrics DEPLOYMENT_ID \
--start-time 2026-10-28T00:00:00Z \
--end-time 2026-10-28T01:00:00Z \
--metrics cpu_usage,memory_usage,worker_count
```
Omit `--metrics` to fetch all available metrics. Time bounds default to the start of
the current hour through now, and the backend picks the resolution from the time
range. Output contains
charts and their time-series data, not traces. This command never prompts; for an
organization-scoped key, set `LANGSMITH_TENANT_ID` to the workspace ID.
### `langgraph deploy scale`
Change a deployment's compute tier, dedicated Postgres tier, or both. Omitted
settings remain unchanged. The backend enforces deployment permissions, supported
tiers, and platform restrictions.
```bash
langgraph deploy scale DEPLOYMENT_ID \
--compute-tier DEDICATED_M --database-tier DEDICATED_L
```
Compute tiers are `SERVERLESS_S`, `SERVERLESS_M`, `SERVERLESS_L`, `DEDICATED_S`,
`DEDICATED_M`, and `DEDICATED_L`. Database tiers are available only for dedicated
deployments: `DEDICATED_S`, `DEDICATED_M`, and `DEDICATED_L`. Availability depends
on the deployment's platform and enabled features.
Scaling prompts for confirmation because it creates a revision, may interrupt
service, and may change billing. Pass `--yes` to confirm without prompting, with
credentials and workspace configured for non-interactive use. The command reports
that scaling was requested; it does not wait for the revision to become healthy.
Arbitrary worker replica overrides are not supported by this command.
## Configuration
The CLI uses a `langgraph.json` configuration file with these key settings:
+104
View File
@@ -13,6 +13,7 @@ from contextlib import contextmanager
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Generic, Protocol, TypeVar
from uuid import UUID
import click
import click.exceptions
@@ -2635,6 +2636,109 @@ def deploy_list(
# ---------------------------------------------------------------------------
@OPT_HOST_API_KEY
@OPT_HOST_URL
@click.argument("deployment_id", type=click.UUID)
@click.option(
"--start-time",
help="ISO8601 start time. Defaults to the start of the current hour.",
)
@click.option("--end-time", help="ISO8601 end time. Defaults to now.")
@click.option(
"--metrics", help="Comma-separated metric names. Defaults to all metrics."
)
@deploy.command(
"metrics", help="[Beta] Fetch deployment monitoring time series as JSON."
)
def deploy_metrics(
api_key: str | None,
host_url: str | None,
deployment_id: UUID,
start_time: str | None,
end_time: str | None,
metrics: str | None,
) -> None:
params = {
key: value
for key, value in {
"start_time": start_time,
"end_time": end_time,
"metrics": metrics,
}.items()
if value is not None
}
client = _create_host_backend_client(host_url, api_key, no_input=True)
result = _call_host_backend_with_optional_tenant(
client,
lambda c: c.get_deployment_metrics(str(deployment_id), params),
no_input=True,
)
click.echo(json_mod.dumps(result))
@OPT_HOST_API_KEY
@OPT_HOST_URL
@click.argument("deployment_id", type=click.UUID)
@click.option(
"--compute-tier",
type=click.Choice(
[
"SERVERLESS_S",
"SERVERLESS_M",
"SERVERLESS_L",
"DEDICATED_S",
"DEDICATED_M",
"DEDICATED_L",
]
),
help="Compute resource tier. Omit to leave compute unchanged.",
)
@click.option(
"--database-tier",
type=click.Choice(["DEDICATED_S", "DEDICATED_M", "DEDICATED_L"]),
help="Postgres resource tier for dedicated deployments. Omit to leave unchanged.",
)
@click.option(
"--yes", is_flag=True, help="Confirm the scaling change without prompting."
)
@deploy.command(
"scale",
help="[Beta] Change compute or Postgres tiers. Creates a revision and may change billing.",
)
def deploy_scale(
api_key: str | None,
host_url: str | None,
deployment_id: UUID,
compute_tier: str | None,
database_tier: str | None,
yes: bool,
) -> None:
payload = {
key: value
for key, value in {
"compute_tier": compute_tier,
"database_tier": database_tier,
}.items()
if value is not None
}
if not payload:
raise click.UsageError("Specify --compute-tier or --database-tier.")
if not yes:
changes = ", ".join(f"{key}={value}" for key, value in payload.items())
click.confirm(
f"Scale deployment {deployment_id} ({changes})? "
"This creates a revision and may change billing or interrupt service.",
abort=True,
)
client = _create_host_backend_client(host_url, api_key, no_input=yes)
_call_host_backend_with_optional_tenant(
client,
lambda c: c.scale_deployment(str(deployment_id), payload),
no_input=yes,
)
click.echo(f"Scaling requested for deployment {deployment_id}.")
@deploy.group(
"listeners",
cls=NestedHelpGroup,
+12
View File
@@ -362,6 +362,18 @@ class HostBackendClient:
f"/v2/deployments/{deployment_id}/revisions/{revision_id}",
)
def get_deployment_metrics(
self, deployment_id: str, params: dict[str, Any]
) -> dict[str, Any]:
return self._request(
"GET", f"/v2/deployments/{deployment_id}/metrics", params=params
)
def scale_deployment(self, deployment_id: str, payload: dict[str, str]) -> None:
self._request(
"PATCH", f"/v2/deployments/{deployment_id}/resource-tiers", payload
)
def get_build_logs(
self, project_id: str, revision_id: str, payload: dict[str, Any]
) -> Any:
@@ -0,0 +1,130 @@
import json
import httpx
import pytest
from click.testing import CliRunner
import langgraph_cli.deploy as deploy_module
from langgraph_cli.cli import cli
from langgraph_cli.host_backend import HostBackendClient
DEPLOYMENT_ID = "019a0000-0000-7000-8000-000000000001"
def invoke(monkeypatch, args, handler, input=None):
client = HostBackendClient(
"https://api.example.com",
"key",
tenant_id="workspace-id",
transport=httpx.MockTransport(handler),
)
monkeypatch.setattr(deploy_module, "HostBackendClient", lambda *a, **kw: client)
return CliRunner().invoke(
cli,
["deploy", *args, "--api-key", "key"],
input=input,
)
@pytest.mark.parametrize("filtered", [False, True])
def test_metrics(monkeypatch, filtered):
charts = {"id": "metrics", "charts": [], "sub_sections": []}
params = (
{
"start_time": "2026-10-28T00:00:00Z",
"end_time": "2026-10-28T01:00:00Z",
"metrics": "cpu_usage,memory_usage",
}
if filtered
else {}
)
def handler(request):
assert request.method == "GET"
assert request.url.path == f"/v2/deployments/{DEPLOYMENT_ID}/metrics"
assert dict(request.url.params) == params
assert request.headers["x-tenant-id"] == "workspace-id"
assert request.headers["x-api-key"] == "key"
return httpx.Response(200, json=charts)
args = ["metrics", DEPLOYMENT_ID]
for key, value in params.items():
args.extend([f"--{key.replace('_', '-')}", value])
result = invoke(monkeypatch, args, handler)
assert result.exit_code == 0, result.output
assert json.loads(result.output) == charts
@pytest.mark.parametrize(
"tiers",
[
{"compute_tier": "SERVERLESS_M"},
{"database_tier": "DEDICATED_L"},
{"compute_tier": "DEDICATED_M", "database_tier": "DEDICATED_L"},
],
)
def test_scale(monkeypatch, tiers):
def handler(request):
assert request.method == "PATCH"
assert request.url.path == f"/v2/deployments/{DEPLOYMENT_ID}/resource-tiers"
assert request.headers["x-tenant-id"] == "workspace-id"
assert json.loads(request.content) == tiers
return httpx.Response(200, json={"secrets": [{"value": "secret-value"}]})
args = ["scale", DEPLOYMENT_ID, "--yes"]
for key, value in tiers.items():
args.extend([f"--{key.replace('_', '-')}", value])
result = invoke(monkeypatch, args, handler)
assert result.exit_code == 0, result.output
assert "Scaling requested" in result.output
assert "secret-value" not in result.output
@pytest.mark.parametrize("answer,expected_code", [("y\n", 0), ("n\n", 1)])
def test_scale_confirmation(monkeypatch, answer, expected_code):
requests = []
def handler(request):
requests.append(request)
return httpx.Response(200, json={})
result = invoke(
monkeypatch,
["scale", DEPLOYMENT_ID, "--compute-tier", "DEDICATED_S"],
handler,
input=answer,
)
assert result.exit_code == expected_code, result.output
assert len(requests) == (1 if expected_code == 0 else 0)
assert "may change billing or interrupt service" in result.output
@pytest.mark.parametrize(
"args",
[
["scale", DEPLOYMENT_ID, "--yes"],
["scale", DEPLOYMENT_ID, "--database-tier", "SERVERLESS_M", "--yes"],
["metrics", DEPLOYMENT_ID, "--step-seconds", "60"],
["metrics", "invalid-id"],
],
)
def test_invalid_options_do_not_send_requests(monkeypatch, args):
def handler(request):
pytest.fail("Invalid options must not send a request")
result = invoke(monkeypatch, args, handler)
assert result.exit_code == 2, result.output
@pytest.mark.parametrize("command", ["metrics", "scale"])
@pytest.mark.parametrize("status", [403, 422, 429])
def test_backend_errors(monkeypatch, command, status):
def handler(request):
return httpx.Response(status, json={"detail": "Operation rejected"})
args = [command, DEPLOYMENT_ID]
if command == "scale":
args.extend(["--compute-tier", "DEDICATED_M", "--yes"])
result = invoke(monkeypatch, args, handler)
assert result.exit_code == 1
assert "Operation rejected" in result.output