Skip to content

Commit 4036958

Browse files
vertex-sdk-botcopybara-github
authored andcommitted
feat: GenAI SDK client - Support agent engine sandbox http request in genai sdk
PiperOrigin-RevId: 816865842
1 parent 46285bf commit 4036958

File tree

3 files changed

+173
-0
lines changed

3 files changed

+173
-0
lines changed

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@
168168
"opentelemetry-exporter-otlp-proto-http < 2",
169169
"pydantic >= 2.11.1, < 3",
170170
"typing_extensions",
171+
"google-cloud-iam",
171172
]
172173

173174
evaluation_extra_require = [
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# pylint: disable=protected-access,bad-continuation,missing-function-docstring
16+
17+
from tests.unit.vertexai.genai.replays import pytest_helper
18+
19+
20+
def test_send_command_sandbox(client):
21+
# agent_engine = client.agent_engines.create()
22+
# assert isinstance(agent_engine, types.AgentEngine)
23+
# assert isinstance(agent_engine.api_resource, types.ReasoningEngine)
24+
# agent_engine_name = "projects/254005681254/locations/us-central1/reasoningEngines/2112984271655272448"
25+
# operation = client.agent_engines.sandboxes.create(
26+
# # name=agent_engine.api_resource.name,
27+
# name=agent_engine_name,
28+
# spec={
29+
# "code_execution_environment": {
30+
# "machineConfig": "MACHINE_CONFIG_VCPU4_RAM4GIB"
31+
# }
32+
# },
33+
# config=types.CreateAgentEngineSandboxConfig(display_name="test_sandbox"),
34+
# )
35+
# assert isinstance(operation, types.AgentEngineSandboxOperation)
36+
37+
client._api_client.project = None
38+
client._api_client.location = None
39+
client._api_client.api_version = None
40+
token = client.agent_engines.sandboxes.generate_access_token(
41+
service_account_email="sign-verify-jwt@mariner-proxy.iam.gserviceaccount.com",
42+
sandbox_id="tenghuil-manual-sandbox-new",
43+
)
44+
response = client.agent_engines.sandboxes.send_command(
45+
http_method="GET",
46+
path="/",
47+
query_params={},
48+
access_token=token,
49+
headers={},
50+
request_dict={},
51+
sandbox_environment=None,
52+
)
53+
# assert token == "xxx"
54+
assert response.body == "Hello World"
55+
# assert response.outputs[0].mime_type == "application/json"
56+
57+
58+
pytestmark = pytest_helper.setup(
59+
file=__file__,
60+
globals_for_file=globals(),
61+
test_method="agent_engines.sandboxes.send_command",
62+
)

vertexai/_genai/sandboxes.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,16 @@
1919
import json
2020
import logging
2121
import mimetypes
22+
import secrets
23+
import time
2224
from typing import Any, Iterator, Optional, Union
2325
from urllib.parse import urlencode
2426

27+
from google import genai
28+
from google.cloud import iam_credentials_v1
2529
from google.genai import _api_module
2630
from google.genai import _common
31+
from google.genai import types as genai_types
2732
from google.genai._common import get_value_by_path as getv
2833
from google.genai._common import set_value_by_path as setv
2934
from google.genai.pagers import Pager
@@ -704,6 +709,111 @@ def delete(
704709
"""
705710
return self._delete(name=name, config=config)
706711

712+
def generate_access_token(
713+
self,
714+
service_account_email: str,
715+
sandbox_id: str,
716+
port: str = "8080",
717+
timeout: int = 3600,
718+
) -> str:
719+
"""Signs a JWT with a Google Cloud service account."""
720+
client = iam_credentials_v1.IAMCredentialsClient()
721+
name = f"projects/-/serviceAccounts/{service_account_email}"
722+
custom_claims = {"port": port, "sandbox_id": sandbox_id}
723+
payload = {
724+
"iat": int(time.time()),
725+
"exp": int(time.time()) + timeout,
726+
"iss": service_account_email,
727+
"nonce": secrets.randbelow(1000000000) + 1,
728+
"aud": "vmaas-proxy-api", # default audience for sandbox proxy
729+
**custom_claims,
730+
}
731+
request = iam_credentials_v1.SignJwtRequest(
732+
name=name,
733+
payload=json.dumps(payload),
734+
)
735+
response = client.sign_jwt(request=request)
736+
return response.signed_jwt
737+
738+
def send_command(
739+
self,
740+
http_method: str,
741+
access_token: str,
742+
sandbox_environment: types.SandboxEnvironment,
743+
path: str = None,
744+
query_params: Optional[dict[str, object]] = None,
745+
headers: Optional[dict[str, str]] = None,
746+
request_dict: Optional[dict[str, object]] = None,
747+
) -> genai_types.HttpResponse:
748+
"""Sends a command to the sandbox."""
749+
headers = headers or {}
750+
request_dict = request_dict or {}
751+
connection_info = sandbox_environment.connection_info
752+
if not connection_info:
753+
raise ValueError("Connection info is not available.")
754+
if connection_info.load_balancer_hostname:
755+
endpoint = "https://" + connection_info.load_balancer_hostname
756+
elif connection_info.load_balancer_ip:
757+
endpoint = "http://" + connection_info.load_balancer_ip
758+
else:
759+
raise ValueError("Load balancer hostname or ip is not available.")
760+
761+
path = path or ""
762+
if query_params:
763+
path = f"{path}?{urlencode(query_params)}"
764+
headers["Authorization"] = f"Bearer {access_token}"
765+
endpoint = endpoint + path if path.startswith("/") else endpoint + "/" + path
766+
http_options = genai_types.HttpOptions(headers=headers, base_url=endpoint)
767+
http_client = genai.Client(vertexai=True, http_options=http_options)
768+
response = http_client._api_client.request(http_method, path, request_dict)
769+
return genai_types.HttpResponse(
770+
headers=response.headers,
771+
body=response.body,
772+
)
773+
774+
def generate_browser_ws_headers(
775+
self,
776+
sandbox_environment: types.SandboxEnvironment,
777+
service_account_email: str,
778+
timeout: int = 3600,
779+
) -> tuple[str, dict[str, str]]:
780+
"""Generates the websocket upgrade headers for the browser."""
781+
sandbox_id = sandbox_environment.name
782+
# port 8080 is the default port for http endpoint.
783+
http_access_token = self.generate_access_token(
784+
service_account_email, sandbox_id, "8080", timeout
785+
)
786+
response = self.send_command(
787+
"GET",
788+
http_access_token,
789+
sandbox_environment,
790+
path="/cdp_ws_endpoint",
791+
)
792+
if not response:
793+
raise ValueError("Failed to get the websocket endpoint.")
794+
body_dict = json.loads(response.body)
795+
ws_path = body_dict["endpoint"]
796+
797+
ws_url = "wss://test-us-central1.autopush-sandbox.vertexai.goog"
798+
if sandbox_environment and sandbox_environment.connection_info:
799+
connection_info = sandbox_environment.connection_info
800+
if connection_info.load_balancer_hostname:
801+
ws_url = "wss://" + connection_info.load_balancer_hostname
802+
elif connection_info.load_balancer_ip:
803+
ws_url = "ws://" + connection_info.load_balancer_ip
804+
else:
805+
raise ValueError("Load balancer hostname or ip is not available.")
806+
ws_url = ws_url + "/" + ws_path
807+
808+
# port 9222 is the default port for the browser websocket endpoint.
809+
ws_access_token = self.generate_access_token(
810+
service_account_email, sandbox_id, "9222", timeout
811+
)
812+
813+
headers = {}
814+
headers["Sec-WebSocket-Protocol"] = f"binary, {ws_access_token}"
815+
return ws_url, headers
816+
707817

708818
class AsyncSandboxes(_api_module.BaseModule):
709819

0 commit comments

Comments
 (0)