mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-19 22:25:44 +02:00
<!-- Replace everything above this line with a 1-2 sentence description of your change. Keep the "Fixes #xx" keyword and update the issue number. --> - Add a property to revisions table metadata column for Google ADK version - This helps us track the deployments that use Google ADK - Similar to this PR: https://github.com/langchain-ai/langchainplus/pull/22087 Read the full contributing guidelines: https://docs.langchain.com/oss/python/contributing/overview > **All contributions must be in English.** See the [language policy](https://docs.langchain.com/oss/python/contributing/overview#language-policy). If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED! Thank you for contributing to LangGraph! Follow these steps to have your pull request considered as ready for review. 1. PR title: Should follow the format: TYPE(SCOPE): DESCRIPTION - feat(langgraph): add multi-tenant support - Allowed TYPE and SCOPE values: https://github.com/langchain-ai/langgraph/blob/main/.github/workflows/pr_lint.yml#L19-L43 2. PR description: - Write 1-2 sentences summarizing the change. - The `Fixes #xx` line at the top is **required** for external contributions — update the issue number and keep the keyword. This links your PR to the approved issue and auto-closes it on merge. - If there are any breaking changes, please clearly describe them. - If this PR depends on another PR being merged first, please include "Depends on #PR_NUMBER" in the description. 3. Run `make format`, `make lint` and `make test` from the root of the package(s) you've modified. - We will not consider a PR unless these three are passing in CI. 4. How did you verify your code works? Additional guidelines: - All external PRs must link to an issue or discussion where a solution has been approved by a maintainer, and you must be assigned to that issue. PRs without prior approval will be closed. - PRs should not touch more than one package unless absolutely necessary. - Do not update the `uv.lock` files or add dependencies to `pyproject.toml` files (even optional ones) unless you have explicit permission to do so by a maintainer. ## Social handles (optional) <!-- If you'd like a shoutout on release, add your socials below --> Twitter: @ LinkedIn: https://linkedin.com/in/
206 lines
6.7 KiB
Python
206 lines
6.7 KiB
Python
"""HTTP client for LangGraph host backend deployments."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import click
|
|
import httpx
|
|
|
|
|
|
class HostBackendError(click.ClickException):
|
|
"""Raised when the host backend returns an error response."""
|
|
|
|
def __init__(self, message: str, status_code: int | None = None):
|
|
super().__init__(message)
|
|
self.status_code = status_code
|
|
|
|
|
|
class HostBackendClient:
|
|
"""Minimal JSON HTTP client for the host backend deployment service."""
|
|
|
|
def __init__(
|
|
self,
|
|
base_url: str,
|
|
api_key: str,
|
|
tenant_id: str | None = None,
|
|
):
|
|
if not base_url:
|
|
raise click.UsageError("Host backend URL is required")
|
|
transport = httpx.HTTPTransport(retries=3)
|
|
headers: dict[str, str] = {
|
|
"X-Api-Key": api_key,
|
|
"Accept": "application/json",
|
|
}
|
|
if tenant_id:
|
|
headers["X-Tenant-ID"] = tenant_id
|
|
self._base_url = base_url.rstrip("/")
|
|
self._client = httpx.Client(
|
|
base_url=self._base_url,
|
|
headers=headers,
|
|
transport=transport,
|
|
timeout=30,
|
|
)
|
|
|
|
def _request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
payload: dict[str, Any] | None = None,
|
|
params: dict[str, Any] | None = None,
|
|
) -> Any:
|
|
try:
|
|
resp = self._client.request(method, path, json=payload, params=params)
|
|
resp.raise_for_status()
|
|
except httpx.HTTPStatusError as err:
|
|
detail = err.response.text or str(err.response.status_code)
|
|
raise HostBackendError(
|
|
f"{method} {path} failed with status {err.response.status_code}: {detail}",
|
|
status_code=err.response.status_code,
|
|
) from None
|
|
except httpx.TransportError as err:
|
|
raise HostBackendError(str(err)) from None
|
|
|
|
if not resp.content:
|
|
return None
|
|
try:
|
|
return resp.json()
|
|
except ValueError as err:
|
|
raise HostBackendError(
|
|
f"Failed to decode response from {path}: {err}"
|
|
) from None
|
|
|
|
def create_deployment(
|
|
self,
|
|
name: str,
|
|
deployment_type: str,
|
|
source: str,
|
|
config_path: str | None = None,
|
|
secrets: list[dict[str, str]] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Create a deployment."""
|
|
payload: dict[str, Any] = {
|
|
"name": name,
|
|
"source": source,
|
|
"source_config": {"deployment_type": deployment_type},
|
|
"source_revision_config": {},
|
|
}
|
|
if source == "internal_source" and config_path:
|
|
payload["source_revision_config"]["langgraph_config_path"] = config_path
|
|
if secrets is not None:
|
|
payload["secrets"] = secrets
|
|
return self._request("POST", "/v2/deployments", payload)
|
|
|
|
def list_deployments(self, name_contains: str = "") -> dict[str, Any]:
|
|
return self._request(
|
|
"GET",
|
|
"/v2/deployments",
|
|
params={"name_contains": name_contains},
|
|
)
|
|
|
|
def get_deployment(self, deployment_id: str) -> dict[str, Any]:
|
|
return self._request("GET", f"/v2/deployments/{deployment_id}")
|
|
|
|
def delete_deployment(self, deployment_id: str) -> None:
|
|
return self._request("DELETE", f"/v2/deployments/{deployment_id}")
|
|
|
|
def request_push_token(self, deployment_id: str) -> dict[str, Any]:
|
|
return self._request(
|
|
"POST",
|
|
f"/v2/deployments/{deployment_id}/push-token",
|
|
)
|
|
|
|
def request_upload_url(self, deployment_id: str) -> dict[str, Any]:
|
|
"""Get a signed GCS URL for uploading the source tarball."""
|
|
return self._request(
|
|
"POST",
|
|
f"/v2/deployments/{deployment_id}/upload-url",
|
|
)
|
|
|
|
def update_deployment(
|
|
self,
|
|
deployment_id: str,
|
|
image_uri: str,
|
|
secrets: list[dict[str, str]] | None = None,
|
|
tracked_packages: list[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
payload: dict[str, Any] = {
|
|
"revision_source": "internal_docker",
|
|
"source_revision_config": {"image_uri": image_uri},
|
|
}
|
|
if tracked_packages:
|
|
payload["tracked_packages"] = tracked_packages
|
|
if secrets is not None:
|
|
payload["secrets"] = secrets
|
|
return self._request(
|
|
"PATCH",
|
|
f"/v2/deployments/{deployment_id}",
|
|
payload,
|
|
)
|
|
|
|
def update_deployment_internal_source(
|
|
self,
|
|
deployment_id: str,
|
|
source_tarball_path: str,
|
|
config_path: str,
|
|
secrets: list[dict[str, str]] | None = None,
|
|
install_command: str | None = None,
|
|
build_command: str | None = None,
|
|
tracked_packages: list[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Trigger a remote build revision with the uploaded tarball."""
|
|
payload: dict[str, Any] = {
|
|
"revision_source": "internal_source",
|
|
"source_revision_config": {
|
|
"source_tarball_path": source_tarball_path,
|
|
"langgraph_config_path": config_path,
|
|
},
|
|
}
|
|
if tracked_packages:
|
|
payload["tracked_packages"] = tracked_packages
|
|
|
|
source_config: dict[str, Any] = {}
|
|
if install_command is not None:
|
|
source_config["install_command"] = install_command
|
|
if build_command is not None:
|
|
source_config["build_command"] = build_command
|
|
if source_config:
|
|
payload["source_config"] = source_config
|
|
|
|
if secrets is not None:
|
|
payload["secrets"] = secrets
|
|
return self._request("PATCH", f"/v2/deployments/{deployment_id}", payload)
|
|
|
|
def list_revisions(self, deployment_id: str, limit: int = 1) -> dict[str, Any]:
|
|
return self._request(
|
|
"GET",
|
|
f"/v2/deployments/{deployment_id}/revisions?limit={limit}",
|
|
)
|
|
|
|
def get_revision(self, deployment_id: str, revision_id: str) -> dict[str, Any]:
|
|
return self._request(
|
|
"GET",
|
|
f"/v2/deployments/{deployment_id}/revisions/{revision_id}",
|
|
)
|
|
|
|
def get_build_logs(
|
|
self, project_id: str, revision_id: str, payload: dict[str, Any]
|
|
) -> Any:
|
|
return self._request(
|
|
"POST",
|
|
f"/v1/projects/{project_id}/revisions/{revision_id}/build_logs",
|
|
payload,
|
|
)
|
|
|
|
def get_deploy_logs(
|
|
self,
|
|
project_id: str,
|
|
payload: dict[str, Any],
|
|
revision_id: str | None = None,
|
|
) -> Any:
|
|
if revision_id:
|
|
path = f"/v1/projects/{project_id}/revisions/{revision_id}/deploy_logs"
|
|
else:
|
|
path = f"/v1/projects/{project_id}/deploy_logs"
|
|
return self._request("POST", path, payload)
|