mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-11-03 04:41:21 +01:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc1b23ffdc | ||
|
|
95cdb86d28 | ||
|
|
0a05f15e21 | ||
|
|
2a30615bd7 |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user