diff --git a/tests/unit/vertexai/genai/replays/test_skills_get.py b/tests/unit/vertexai/genai/replays/test_skills_get.py new file mode 100644 index 0000000000..b19a30d065 --- /dev/null +++ b/tests/unit/vertexai/genai/replays/test_skills_get.py @@ -0,0 +1,39 @@ +"""Tests the skills.get() method against the autopush endpoint.""" + +from google.api_core import exceptions +from tests.unit.vertexai.genai.replays import pytest_helper +import pytest + +PROJECT_ID = "srbai-testing" +REGION = "us-central1" +# SKILL_ID = "5578834038405201920" +SKILL_ID = "7184367305562783744" +ENDPOINT = f"{REGION}-autopush-aiplatform.sandbox.googleapis.com" + +# # Configure HTTP options to target the autopush endpoint +# my_http_options = genai_types.HttpOptions( +# api_version="v1beta1", +# base_url=f"https://{ENDPOINT}/v1beta1/" # <---APPENDED /v1beta1/ here +# ) + +pytestmark = pytest_helper.setup( + file=__file__, + globals_for_file=globals(), + # http_options=my_http_options, +) + + +def test_get_skill(client): # client fixture is injected by pytest_helper.setup + """Tests the skills.get() method against the autopush endpoint.""" + + client._api_client._http_options.base_url = ( + "https://us-central1-autopush-aiplatform.sandbox.googleapis.com" + ) + skill_name = f"projects/{PROJECT_ID}/locations/{REGION}/skills/{SKILL_ID}" + + try: + skill = client.skills.get(name=skill_name) + assert skill.name == skill_name + + except exceptions.GoogleAPIError as e: + pytest.fail(f"Error calling client.skills.get(): {e}") diff --git a/tests/unit/vertexai/genai/test_genai_skills.py b/tests/unit/vertexai/genai/test_genai_skills.py new file mode 100644 index 0000000000..40ea150bfb --- /dev/null +++ b/tests/unit/vertexai/genai/test_genai_skills.py @@ -0,0 +1,51 @@ +# //third_party/py/google/cloud/aiplatform/tests/unit/vertexai/genai/test_genai_skills.py +import pytest +from unittest import mock +from vertexai import client as vertexai_client +from vertexai import _genai as genai +from google.genai import client as genai_client +from google.genai import deps + + +@pytest.fixture +def mock_genai_client(): + return mock.create_autospec(genai_client.Client) + + +@pytest.fixture +def skills_client(mock_genai_client): + creds = mock.MagicMock() + creds.token = "test_token" + client = vertexai_client.Client( + project="test-project", location="test-location", credentials=creds + ) + client._genai_client = mock_genai_client + return client.skills + + +class TestGenaiSkills: + mock_get_skill_response = { + "name": "projects/test-project/locations/test-location/skills/test-skill", + "displayName": "My Test Skill", + # Add other expected fields from the Skill proto + } + + def test_get_skill(self, skills_client, mock_genai_client): + """Tests the get_skill method.""" + mock_genai_client.post.return_value = deps.Response( + result=self.mock_get_skill_response, + request=mock.MagicMock(), + response=mock.MagicMock(), + ) + + skill_name = "projects/test-project/locations/test-location/skills/test-skill" + skill = skills_client.get(name=skill_name) + + mock_genai_client.post.assert_called_once() + call_args = mock_genai_client.post.call_args + assert call_args[0][0] == skill_name + assert call_args[1]["method"] == "GET" + + assert isinstance(skill, genai.types.Skill) + assert skill.name == skill_name + assert skill.display_name == "My Test Skill" diff --git a/vertexai/_genai/client.py b/vertexai/_genai/client.py index 2e43782554..77d8a9aadc 100644 --- a/vertexai/_genai/client.py +++ b/vertexai/_genai/client.py @@ -35,6 +35,7 @@ prompt_optimizer as prompt_optimizer_module, ) from vertexai._genai import prompts as prompts_module + from vertexai._genai import skills as skills_module from vertexai._genai import live as live_module @@ -52,6 +53,7 @@ def __init__(self, api_client: genai_client.BaseApiClient): # type: ignore[name self._prompt_optimizer: Optional[ModuleType] = None self._prompts: Optional[ModuleType] = None self._datasets: Optional[ModuleType] = None + self._skills: Optional[ModuleType] = None @property @_common.experimental_warning( @@ -124,6 +126,15 @@ def datasets(self) -> "datasets_module.AsyncDatasets": ) return self._datasets.AsyncDatasets(self._api_client) # type: ignore[no-any-return] + @property + def skills(self) -> "skills_module.AsyncSkills": + if self._skills is None: + self._skills = importlib.import_module( + ".skills", + __package__, + ) + return self._skills.AsyncSkills(self._api_client) # type: ignore[no-any-return] + async def aclose(self) -> None: """Closes the async client explicitly. @@ -239,6 +250,7 @@ def __init__( self._agent_engines: Optional[ModuleType] = None self._prompts: Optional[ModuleType] = None self._datasets: Optional[ModuleType] = None + self._skills: Optional[ModuleType] = None @property def evals(self) -> "evals_module.Evals": @@ -335,3 +347,12 @@ def datasets(self) -> "datasets_module.Datasets": __package__, ) return self._datasets.Datasets(self._api_client) # type: ignore[no-any-return] + + @property + def skills(self) -> "skills_module.Skills": + if self._skills is None: + self._skills = importlib.import_module( + ".skills", + __package__, + ) + return self._skills.Skills(self._api_client) # type: ignore[no-any-return] diff --git a/vertexai/_genai/skills.py b/vertexai/_genai/skills.py new file mode 100644 index 0000000000..771adc8319 --- /dev/null +++ b/vertexai/_genai/skills.py @@ -0,0 +1,192 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Code generated by the Google Gen AI SDK generator DO NOT EDIT. + +import json +import logging +from typing import Any, Optional, Union +from urllib.parse import urlencode + +from google.genai import _api_module +from google.genai import _common +from google.genai._common import get_value_by_path as getv +from google.genai._common import set_value_by_path as setv + +from . import types + +logger = logging.getLogger("vertexai_genai.skills") + + +def _GetSkillRequestParameters_to_vertex( + from_object: Union[dict[str, Any], object], + parent_object: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + to_object: dict[str, Any] = {} + if getv(from_object, ["name"]) is not None: + setv(to_object, ["_url", "name"], getv(from_object, ["name"])) + + if getv(from_object, ["config"]) is not None: + setv(to_object, ["config"], getv(from_object, ["config"])) + + return to_object + + +class Skills(_api_module.BaseModule): + """Class for managing Skills in the Skill Registry.""" + + def get( + self, *, name: str, config: Optional[types.BaseConfigOrDict] = None + ) -> types.Skill: + """ + Gets a Skill. + """ + + parameter_model = types._GetSkillRequestParameters( + name=name, + config=config, + ) + + request_url_dict: Optional[dict[str, str]] + if not self._api_client.vertexai: + raise ValueError( + "This method is only supported in the Gemini Enterprise Agent Platform (previously known as Vertex AI) client." + ) + else: + request_dict = _GetSkillRequestParameters_to_vertex(parameter_model) + request_url_dict = request_dict.get("_url") + if request_url_dict: + path = "{name}".format_map(request_url_dict) + else: + path = "{name}" + + query_params = request_dict.get("_query") + if query_params: + path = f"{path}?{urlencode(query_params)}" + # TODO: remove the hack that pops config. + request_dict.pop("config", None) + + http_options: Optional[types.HttpOptions] = None + if ( + parameter_model.config is not None + and parameter_model.config.http_options is not None + ): + http_options = parameter_model.config.http_options + + request_dict = _common.convert_to_dict(request_dict) + request_dict = _common.encode_unserializable_types(request_dict) + + response = self._api_client.request("get", path, request_dict, http_options) + + response_dict = {} if not response.body else json.loads(response.body) + + return_value = types.Skill._from_response( + response=response_dict, + kwargs=( + { + "config": { + "response_schema": getattr( + parameter_model.config, "response_schema", None + ), + "response_json_schema": getattr( + parameter_model.config, "response_json_schema", None + ), + "include_all_fields": getattr( + parameter_model.config, "include_all_fields", None + ), + } + } + if getattr(parameter_model, "config", None) + else {} + ), + ) + + self._api_client._verify_response(return_value) + return return_value + + +class AsyncSkills(_api_module.BaseModule): + """Class for managing Skills in the Skill Registry.""" + + async def get( + self, *, name: str, config: Optional[types.BaseConfigOrDict] = None + ) -> types.Skill: + """ + Gets a Skill. + """ + + parameter_model = types._GetSkillRequestParameters( + name=name, + config=config, + ) + + request_url_dict: Optional[dict[str, str]] + if not self._api_client.vertexai: + raise ValueError( + "This method is only supported in the Gemini Enterprise Agent Platform (previously known as Vertex AI) client." + ) + else: + request_dict = _GetSkillRequestParameters_to_vertex(parameter_model) + request_url_dict = request_dict.get("_url") + if request_url_dict: + path = "{name}".format_map(request_url_dict) + else: + path = "{name}" + + query_params = request_dict.get("_query") + if query_params: + path = f"{path}?{urlencode(query_params)}" + # TODO: remove the hack that pops config. + request_dict.pop("config", None) + + http_options: Optional[types.HttpOptions] = None + if ( + parameter_model.config is not None + and parameter_model.config.http_options is not None + ): + http_options = parameter_model.config.http_options + + request_dict = _common.convert_to_dict(request_dict) + request_dict = _common.encode_unserializable_types(request_dict) + + response = await self._api_client.async_request( + "get", path, request_dict, http_options + ) + + response_dict = {} if not response.body else json.loads(response.body) + + return_value = types.Skill._from_response( + response=response_dict, + kwargs=( + { + "config": { + "response_schema": getattr( + parameter_model.config, "response_schema", None + ), + "response_json_schema": getattr( + parameter_model.config, "response_json_schema", None + ), + "include_all_fields": getattr( + parameter_model.config, "include_all_fields", None + ), + } + } + if getattr(parameter_model, "config", None) + else {} + ), + ) + + self._api_client._verify_response(return_value) + return return_value diff --git a/vertexai/_genai/types/__init__.py b/vertexai/_genai/types/__init__.py index d278f1cce3..ea244a0286 100644 --- a/vertexai/_genai/types/__init__.py +++ b/vertexai/_genai/types/__init__.py @@ -79,6 +79,7 @@ from .common import _GetEvaluationSetParameters from .common import _GetMultimodalDatasetOperationParameters from .common import _GetMultimodalDatasetParameters +from .common import _GetSkillRequestParameters from .common import _IngestEventsRequestParameters from .common import _ListAgentEngineMemoryRequestParameters from .common import _ListAgentEngineMemoryRevisionsRequestParameters @@ -172,6 +173,9 @@ from .common import AssessDatasetConfig from .common import AssessDatasetConfigDict from .common import AssessDatasetConfigOrDict +from .common import BaseConfig +from .common import BaseConfigDict +from .common import BaseConfigOrDict from .common import BatchPredictionResourceUsageAssessmentConfig from .common import BatchPredictionResourceUsageAssessmentConfigDict from .common import BatchPredictionResourceUsageAssessmentConfigOrDict @@ -550,6 +554,12 @@ from .common import GetPromptConfig from .common import GetPromptConfigDict from .common import GetPromptConfigOrDict +from .common import HttpOptions +from .common import HttpOptionsDict +from .common import HttpOptionsOrDict +from .common import HttpRetryOptions +from .common import HttpRetryOptionsDict +from .common import HttpRetryOptionsOrDict from .common import IdentityType from .common import Importance from .common import IngestEventsConfig @@ -943,6 +953,7 @@ from .common import ReservationAffinity from .common import ReservationAffinityDict from .common import ReservationAffinityOrDict +from .common import ResourceScope from .common import ResponseCandidate from .common import ResponseCandidateDict from .common import ResponseCandidateOrDict @@ -1129,6 +1140,9 @@ from .common import SessionEventDict from .common import SessionEventOrDict from .common import SessionOrDict +from .common import Skill +from .common import SkillDict +from .common import SkillOrDict from .common import State from .common import Strategy from .common import StructuredMemoryConfig @@ -2271,6 +2285,18 @@ "UpdatePromptConfig", "UpdatePromptConfigDict", "UpdatePromptConfigOrDict", + "HttpRetryOptions", + "HttpRetryOptionsDict", + "HttpRetryOptionsOrDict", + "HttpOptions", + "HttpOptionsDict", + "HttpOptionsOrDict", + "BaseConfig", + "BaseConfigDict", + "BaseConfigOrDict", + "Skill", + "SkillDict", + "SkillOrDict", "PromptOptimizerConfig", "PromptOptimizerConfigDict", "PromptOptimizerConfigOrDict", @@ -2378,6 +2404,7 @@ "OptimizeTarget", "MemoryMetadataMergeStrategy", "GenerateMemoriesResponseGeneratedMemoryAction", + "ResourceScope", "PromptOptimizerMethod", "OptimizationMethod", "PromptData", @@ -2486,6 +2513,7 @@ "_CustomJobParameters", "_GetCustomJobParameters", "_OptimizeRequestParameters", + "_GetSkillRequestParameters", "evals", "agent_engines", "prompts", diff --git a/vertexai/_genai/types/common.py b/vertexai/_genai/types/common.py index 13118e782c..a1aaee6713 100644 --- a/vertexai/_genai/types/common.py +++ b/vertexai/_genai/types/common.py @@ -415,6 +415,17 @@ class GenerateMemoriesResponseGeneratedMemoryAction(_common.CaseInSensitiveEnum) """The memory was deleted.""" +class ResourceScope(_common.CaseInSensitiveEnum): + """Resource scope.""" + + COLLECTION = "COLLECTION" + """When setting base_url, this value configures resource scope to be the collection. + The resource name will not include api version, project, or location. + For example, if base_url is set to "https://aiplatform.googleapis.com", + then the resource name for a Model would be + "https://aiplatform.googleapis.com/publishers/google/models/gemini-3-pro-preview""" + + class PromptOptimizerMethod(_common.CaseInSensitiveEnum): """The method for data driven prompt optimization.""" @@ -16183,6 +16194,261 @@ class _UpdateDatasetParametersDict(TypedDict, total=False): ] +class HttpRetryOptions(_common.BaseModel): + """HTTP retry options to be used in each of the requests.""" + + attempts: Optional[int] = Field( + default=None, + description="""Maximum number of attempts, including the original request. + If 0 or 1, it means no retries. If not specified, default to 5.""", + ) + initial_delay: Optional[float] = Field( + default=None, + description="""Initial delay before the first retry, in fractions of a second. If not specified, default to 1.0 second.""", + ) + max_delay: Optional[float] = Field( + default=None, + description="""Maximum delay between retries, in fractions of a second. If not specified, default to 60.0 seconds.""", + ) + exp_base: Optional[float] = Field( + default=None, + description="""Multiplier by which the delay increases after each attempt. If not specified, default to 2.0.""", + ) + jitter: Optional[float] = Field( + default=None, + description="""Randomness factor for the delay. If not specified, default to 1.0.""", + ) + http_status_codes: Optional[list[int]] = Field( + default=None, + description="""List of HTTP status codes that should trigger a retry. + If not specified, a default set of retryable codes (408, 429, and 5xx) may be used.""", + ) + + +class HttpRetryOptionsDict(TypedDict, total=False): + """HTTP retry options to be used in each of the requests.""" + + attempts: Optional[int] + """Maximum number of attempts, including the original request. + If 0 or 1, it means no retries. If not specified, default to 5.""" + + initial_delay: Optional[float] + """Initial delay before the first retry, in fractions of a second. If not specified, default to 1.0 second.""" + + max_delay: Optional[float] + """Maximum delay between retries, in fractions of a second. If not specified, default to 60.0 seconds.""" + + exp_base: Optional[float] + """Multiplier by which the delay increases after each attempt. If not specified, default to 2.0.""" + + jitter: Optional[float] + """Randomness factor for the delay. If not specified, default to 1.0.""" + + http_status_codes: Optional[list[int]] + """List of HTTP status codes that should trigger a retry. + If not specified, a default set of retryable codes (408, 429, and 5xx) may be used.""" + + +HttpRetryOptionsOrDict = Union[HttpRetryOptions, HttpRetryOptionsDict] + + +class HttpOptions(_common.BaseModel): + """HTTP options to be used in each of the requests.""" + + base_url: Optional[str] = Field( + default=None, + description="""The base URL for the AI platform service endpoint.""", + ) + base_url_resource_scope: Optional[ResourceScope] = Field( + default=None, + description="""The resource scope used to constructing the resource name when base_url is set""", + ) + api_version: Optional[str] = Field( + default=None, description="""Specifies the version of the API to use.""" + ) + headers: Optional[dict[str, str]] = Field( + default=None, + description="""Additional HTTP headers to be sent with the request.""", + ) + timeout: Optional[int] = Field( + default=None, description="""Timeout for the request in milliseconds.""" + ) + client_args: Optional[dict[str, Any]] = Field( + default=None, description="""Args passed to the HTTP client.""" + ) + async_client_args: Optional[dict[str, Any]] = Field( + default=None, description="""Args passed to the async HTTP client.""" + ) + extra_body: Optional[dict[str, Any]] = Field( + default=None, + description="""Extra parameters to add to the request body. + The structure must match the backend API's request structure. + - Gemini Enterprise Agent Platform backend API docs: https://cloud.google.com/vertex-ai/docs/reference/rest + - GeminiAPI backend API docs: https://ai.google.dev/api/rest""", + ) + retry_options: Optional[HttpRetryOptions] = Field( + default=None, description="""HTTP retry options for the request.""" + ) + + +class HttpOptionsDict(TypedDict, total=False): + """HTTP options to be used in each of the requests.""" + + base_url: Optional[str] + """The base URL for the AI platform service endpoint.""" + + base_url_resource_scope: Optional[ResourceScope] + """The resource scope used to constructing the resource name when base_url is set""" + + api_version: Optional[str] + """Specifies the version of the API to use.""" + + headers: Optional[dict[str, str]] + """Additional HTTP headers to be sent with the request.""" + + timeout: Optional[int] + """Timeout for the request in milliseconds.""" + + client_args: Optional[dict[str, Any]] + """Args passed to the HTTP client.""" + + async_client_args: Optional[dict[str, Any]] + """Args passed to the async HTTP client.""" + + extra_body: Optional[dict[str, Any]] + """Extra parameters to add to the request body. + The structure must match the backend API's request structure. + - Gemini Enterprise Agent Platform backend API docs: https://cloud.google.com/vertex-ai/docs/reference/rest + - GeminiAPI backend API docs: https://ai.google.dev/api/rest""" + + retry_options: Optional[HttpRetryOptionsDict] + """HTTP retry options for the request.""" + + +HttpOptionsOrDict = Union[HttpOptions, HttpOptionsDict] + + +class BaseConfig(_common.BaseModel): + + http_options: Optional[HttpOptions] = Field( + default=None, description="""Used to override HTTP request options.""" + ) + + +class BaseConfigDict(TypedDict, total=False): + + http_options: Optional[HttpOptionsDict] + """Used to override HTTP request options.""" + + +BaseConfigOrDict = Union[BaseConfig, BaseConfigDict] + + +class _GetSkillRequestParameters(_common.BaseModel): + """Parameters for GetSkillRequest.""" + + name: Optional[str] = Field( + default=None, + description="""The resource name of the Skill to retrieve. Format: projects/{project}/locations/{location}/skills/{skill}""", + ) + config: Optional[BaseConfig] = Field(default=None, description="""""") + + +class _GetSkillRequestParametersDict(TypedDict, total=False): + """Parameters for GetSkillRequest.""" + + name: Optional[str] + """The resource name of the Skill to retrieve. Format: projects/{project}/locations/{location}/skills/{skill}""" + + config: Optional[BaseConfigDict] + """""" + + +_GetSkillRequestParametersOrDict = Union[ + _GetSkillRequestParameters, _GetSkillRequestParametersDict +] + + +class Skill(_common.BaseModel): + """Represents a Skill resource. + + Patches the type from the discovery document. + """ + + name: Optional[str] = Field( + default=None, + description="""Identifier. The resource name of the Skill. Format: `projects/{project}/locations/{location}/skills/{skill}`""", + ) + create_time: Optional[datetime.datetime] = Field( + default=None, + description="""Output only. Timestamp when this Skill was created.""", + ) + update_time: Optional[datetime.datetime] = Field( + default=None, + description="""Output only. Timestamp when this Skill was most recently updated.""", + ) + display_name: Optional[str] = Field( + default=None, + description="""Required. Provides the display name of the Skill. This should align with `name` in the `SKILL.md` file.""", + ) + description: Optional[str] = Field( + default=None, + description="""Required. Describes the Skill. Should describe both what the skill does and when to use it. Should include specific keywords that help agents identify relevant tasks. This should align with `description` in the `SKILL.md` file.""", + ) + license: Optional[str] = Field( + default=None, + description="""Optional. Specifies the license of the Skill. This should be an SPDX license identifier (e.g., "MIT", "Apache-2.0"). See https://spdx.org/licenses/. This should align with `license` in the `SKILL.md` file.""", + ) + compatibility: Optional[str] = Field( + default=None, + description="""Optional. Specifies the compatibility of the Skill. Indicates environment requirements (intended product, system packages, network access, etc.). This should align with `compatibility` in the `SKILL.md` file.""", + ) + zipped_filesystem: Optional[str] = Field( + default=None, + description="""Required. Provides the zipped filesystem of the Skill. This should contain the `SKILL.md` file at the root of the zip and optional directories for scripts, references, and assets. Directory should align with the directory structure specified at https://agentskills.io/specification#directory-structure.""", + ) + state: Optional[State] = Field( + default=None, description="""Output only. The state of the Skill.""" + ) + + +class SkillDict(TypedDict, total=False): + """Represents a Skill resource. + + Patches the type from the discovery document. + """ + + name: Optional[str] + """Identifier. The resource name of the Skill. Format: `projects/{project}/locations/{location}/skills/{skill}`""" + + create_time: Optional[datetime.datetime] + """Output only. Timestamp when this Skill was created.""" + + update_time: Optional[datetime.datetime] + """Output only. Timestamp when this Skill was most recently updated.""" + + display_name: Optional[str] + """Required. Provides the display name of the Skill. This should align with `name` in the `SKILL.md` file.""" + + description: Optional[str] + """Required. Describes the Skill. Should describe both what the skill does and when to use it. Should include specific keywords that help agents identify relevant tasks. This should align with `description` in the `SKILL.md` file.""" + + license: Optional[str] + """Optional. Specifies the license of the Skill. This should be an SPDX license identifier (e.g., "MIT", "Apache-2.0"). See https://spdx.org/licenses/. This should align with `license` in the `SKILL.md` file.""" + + compatibility: Optional[str] + """Optional. Specifies the compatibility of the Skill. Indicates environment requirements (intended product, system packages, network access, etc.). This should align with `compatibility` in the `SKILL.md` file.""" + + zipped_filesystem: Optional[str] + """Required. Provides the zipped filesystem of the Skill. This should contain the `SKILL.md` file at the root of the zip and optional directories for scripts, references, and assets. Directory should align with the directory structure specified at https://agentskills.io/specification#directory-structure.""" + + state: Optional[State] + """Output only. The state of the Skill.""" + + +SkillOrDict = Union[Skill, SkillDict] + + class PromptOptimizerConfig(_common.BaseModel): """VAPO Prompt Optimizer Config."""