Skip to content

Commit 769812a

Browse files
vertex-sdk-botcopybara-github
authored andcommitted
feat: Add Create Skill method for Vertex AI Skill Registry SDK
PiperOrigin-RevId: 910161153
1 parent c145803 commit 769812a

8 files changed

Lines changed: 1336 additions & 0 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Copyright 2026 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+
"""Tests the skills.create() method against the Vertex AI endpoint using replays."""
16+
17+
import io
18+
import os
19+
import tempfile
20+
import zipfile
21+
22+
from tests.unit.vertexai.genai.replays import pytest_helper
23+
from vertexai._genai import types
24+
25+
# MANDATORY: Initialize the replay test framework for this module
26+
pytestmark = pytest_helper.setup(
27+
file=__file__,
28+
globals_for_file=globals(),
29+
)
30+
31+
32+
def test_create_skill(client):
33+
"""Tests the creation of a skill using `client.skills.create()`."""
34+
# Target the autopush sandbox endpoint for the Skill Registry API
35+
client._api_client._http_options.base_url = (
36+
"https://us-central1-autopush-aiplatform.sandbox.googleapis.com"
37+
)
38+
39+
with tempfile.TemporaryDirectory() as tmpdir:
40+
# Create a dummy skill structure (SKILL.md is required by the spec)
41+
with open(os.path.join(tmpdir, "SKILL.md"), "w") as f:
42+
f.write("# My Replay Skill\nThis is a test skill for replay tests.")
43+
44+
skill = client.skills.create(
45+
display_name="My Replay Skill",
46+
description="My Replay Skill Description",
47+
config=types.CreateSkillConfig(local_path=tmpdir, wait_for_completion=True),
48+
)
49+
50+
assert skill.name is not None
51+
assert skill.display_name == "My Replay Skill"
52+
assert skill.description == "My Replay Skill Description"
53+
54+
55+
def test_create_skill_with_prezipped_bytes(client):
56+
"""Tests the creation of a skill with pre-zipped bytes."""
57+
# Target the autopush sandbox endpoint for the Skill Registry API
58+
client._api_client._http_options.base_url = (
59+
"https://us-central1-autopush-aiplatform.sandbox.googleapis.com"
60+
)
61+
62+
zip_buffer = io.BytesIO()
63+
zinfo = zipfile.ZipInfo("SKILL.md", date_time=(1980, 1, 1, 0, 0, 0))
64+
with zipfile.ZipFile(zip_buffer, "w") as zip_file:
65+
zip_file.writestr(zinfo, "# My Zipped Replay Skill\nThis is a test.")
66+
zipped_bytes = zip_buffer.getvalue()
67+
68+
skill = client.skills.create(
69+
display_name="My Zipped Replay Skill",
70+
description="My Zipped Replay Skill Description",
71+
config=types.CreateSkillConfig(
72+
zipped_filesystem=zipped_bytes, wait_for_completion=True
73+
),
74+
)
75+
76+
assert skill.name is not None
77+
assert skill.display_name == "My Zipped Replay Skill"
78+
assert skill.description == "My Zipped Replay Skill Description"
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Tests the skills.get() method against the autopush endpoint."""
2+
3+
from google.api_core import exceptions
4+
from tests.unit.vertexai.genai.replays import pytest_helper
5+
import pytest
6+
7+
PROJECT_ID = "demo-project"
8+
REGION = "us-central1"
9+
SKILL_ID = "7184367305562783744"
10+
# target the autopush sandbox endpoint for the Skill Registry API
11+
ENDPOINT = f"{REGION}-autopush-aiplatform.sandbox.googleapis.com"
12+
13+
14+
pytestmark = pytest_helper.setup(
15+
file=__file__,
16+
globals_for_file=globals(),
17+
)
18+
19+
20+
def test_get_skill(client): # client fixture is injected by pytest_helper.setup
21+
"""Tests the skills.get() method against the autopush endpoint."""
22+
23+
client._api_client._http_options.base_url = (
24+
"https://us-central1-autopush-aiplatform.sandbox.googleapis.com"
25+
)
26+
skill_name = f"projects/{PROJECT_ID}/locations/{REGION}/skills/{SKILL_ID}"
27+
28+
try:
29+
skill = client.skills.get(name=skill_name)
30+
assert skill.name == skill_name
31+
32+
except exceptions.GoogleAPIError as e:
33+
pytest.fail(f"Error calling client.skills.get(): {e}")
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# //third_party/py/google/cloud/aiplatform/tests/unit/vertexai/genai/test_genai_skills.py
2+
import json
3+
from unittest import mock
4+
from vertexai import _genai as genai
5+
from vertexai._genai import client as vertexai_client
6+
from google.genai import types as genai_types
7+
import pytest
8+
9+
10+
@pytest.fixture
11+
def skills_client():
12+
creds = mock.MagicMock()
13+
creds.token = "test_token"
14+
client = vertexai_client.Client(
15+
project="test-project", location="test-location", credentials=creds
16+
)
17+
return client.skills
18+
19+
20+
class TestGenaiSkills:
21+
mock_get_skill_response = {
22+
"name": "projects/test-project/locations/test-location/skills/test-skill",
23+
"displayName": "My Test Skill",
24+
}
25+
26+
def test_get_skill(self, skills_client):
27+
"""Tests the get_skill method."""
28+
with mock.patch.object(skills_client._api_client, "request") as request_mock:
29+
request_mock.return_value = genai_types.HttpResponse(
30+
body=json.dumps(self.mock_get_skill_response)
31+
)
32+
skill_name = (
33+
"projects/test-project/locations/test-location/skills/test-skill"
34+
)
35+
skill = skills_client.get(name=skill_name)
36+
request_mock.assert_called_with(
37+
"get",
38+
skill_name,
39+
{"_url": {"name": skill_name}},
40+
None,
41+
)
42+
assert isinstance(skill, genai.types.Skill)
43+
assert skill.name == skill_name
44+
assert skill.display_name == "My Test Skill"

vertexai/_genai/_skills_utils.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Copyright 2026 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+
"""Utility functions for Skills."""
16+
17+
import asyncio
18+
import base64
19+
import io
20+
import os
21+
import time
22+
from typing import Any, Awaitable, Callable
23+
import zipfile
24+
25+
26+
def zip_directory(directory_path: str) -> bytes:
27+
"""Zips a directory into memory and returns the bytes.
28+
29+
Args:
30+
directory_path (str): Required. The local path to the directory.
31+
32+
Returns:
33+
bytes: The zipped directory content.
34+
"""
35+
if not os.path.isdir(directory_path):
36+
raise ValueError(f"Path is not a directory: {directory_path}")
37+
38+
zip_buffer = io.BytesIO()
39+
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
40+
for root, _, files in os.walk(directory_path):
41+
for file in files:
42+
file_path = os.path.join(root, file)
43+
arcname = os.path.relpath(file_path, directory_path)
44+
45+
# Read actual file data
46+
with open(file_path, "rb") as f:
47+
file_data = f.read()
48+
49+
# Use deterministic ZipInfo (mtime: 1980-01-01 00:00:00)
50+
zinfo = zipfile.ZipInfo(arcname, date_time=(1980, 1, 1, 0, 0, 0))
51+
zinfo.compress_type = zipfile.ZIP_DEFLATED
52+
zinfo.external_attr = 0o644 << 16 # Constant file permissions
53+
54+
zip_file.writestr(zinfo, file_data)
55+
return zip_buffer.getvalue()
56+
57+
58+
def get_zipped_filesystem_payload(directory_path: str) -> str:
59+
"""Zips a directory and base64-encodes the result to a UTF-8 string.
60+
61+
Args:
62+
directory_path (str): Required. The local path to the directory.
63+
64+
Returns:
65+
str: The base64-encoded zipped directory.
66+
"""
67+
zip_bytes = zip_directory(directory_path)
68+
return base64.b64encode(zip_bytes).decode("utf-8")
69+
70+
71+
def await_operation(
72+
*,
73+
operation_name: str,
74+
get_operation_fn: Callable[..., Any],
75+
poll_interval_seconds: float = 10.0,
76+
) -> Any:
77+
"""Waits for a long running operation to complete.
78+
79+
Args:
80+
operation_name (str): Required. The name of the operation.
81+
get_operation_fn (Callable): Required. Function to get the operation
82+
status.
83+
poll_interval_seconds (float): The interval between polls in seconds.
84+
85+
Returns:
86+
Any: The completed operation.
87+
"""
88+
operation = get_operation_fn(operation_name=operation_name)
89+
while not operation.done:
90+
time.sleep(poll_interval_seconds)
91+
operation = get_operation_fn(operation_name=operation.name)
92+
return operation
93+
94+
95+
async def await_operation_async(
96+
*,
97+
operation_name: str,
98+
get_operation_fn: Callable[..., Awaitable[Any]],
99+
poll_interval_seconds: float = 10.0,
100+
) -> Any:
101+
"""Waits for a long running operation to complete asynchronously.
102+
103+
Args:
104+
operation_name (str): Required. The name of the operation.
105+
get_operation_fn (Callable): Required. Async function to get the operation
106+
status.
107+
poll_interval_seconds (float): The interval between polls in seconds.
108+
109+
Returns:
110+
Any: The completed operation.
111+
"""
112+
operation = await get_operation_fn(operation_name=operation_name)
113+
while not operation.done:
114+
await asyncio.sleep(poll_interval_seconds)
115+
operation = await get_operation_fn(operation_name=operation.name)
116+
return operation

vertexai/_genai/client.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
prompt_optimizer as prompt_optimizer_module,
3636
)
3737
from vertexai._genai import prompts as prompts_module
38+
from vertexai._genai import skills as skills_module
3839
from vertexai._genai import live as live_module
3940

4041

@@ -52,6 +53,7 @@ def __init__(self, api_client: genai_client.BaseApiClient): # type: ignore[name
5253
self._prompt_optimizer: Optional[ModuleType] = None
5354
self._prompts: Optional[ModuleType] = None
5455
self._datasets: Optional[ModuleType] = None
56+
self._skills: Optional[ModuleType] = None
5557

5658
@property
5759
@_common.experimental_warning(
@@ -124,6 +126,15 @@ def datasets(self) -> "datasets_module.AsyncDatasets":
124126
)
125127
return self._datasets.AsyncDatasets(self._api_client) # type: ignore[no-any-return]
126128

129+
@property
130+
def skills(self) -> "skills_module.AsyncSkills":
131+
if self._skills is None:
132+
self._skills = importlib.import_module(
133+
".skills",
134+
__package__,
135+
)
136+
return self._skills.AsyncSkills(self._api_client) # type: ignore[no-any-return]
137+
127138
async def aclose(self) -> None:
128139
"""Closes the async client explicitly.
129140
@@ -239,6 +250,7 @@ def __init__(
239250
self._agent_engines: Optional[ModuleType] = None
240251
self._prompts: Optional[ModuleType] = None
241252
self._datasets: Optional[ModuleType] = None
253+
self._skills: Optional[ModuleType] = None
242254

243255
@property
244256
def evals(self) -> "evals_module.Evals":
@@ -335,3 +347,12 @@ def datasets(self) -> "datasets_module.Datasets":
335347
__package__,
336348
)
337349
return self._datasets.Datasets(self._api_client) # type: ignore[no-any-return]
350+
351+
@property
352+
def skills(self) -> "skills_module.Skills":
353+
if self._skills is None:
354+
self._skills = importlib.import_module(
355+
".skills",
356+
__package__,
357+
)
358+
return self._skills.Skills(self._api_client) # type: ignore[no-any-return]

0 commit comments

Comments
 (0)