diff --git a/google/genai/__init__.py b/google/genai/__init__.py index e9d37f6bb..a395d14bf 100644 --- a/google/genai/__init__.py +++ b/google/genai/__init__.py @@ -25,12 +25,12 @@ __version__ = version.__version__ -__all__ = ['Client', 'interactions', 'types'] +__all__ = ['Client', 'credentials', 'interactions', 'types'] def __getattr__(name: str) -> Any: - if name == 'interactions': - module = importlib.import_module('.interactions', __name__) + if name in ('credentials', 'interactions'): + module = importlib.import_module(f'.{name}', __name__) globals()[name] = module return module raise AttributeError(f'module {__name__!r} has no attribute {name!r}') diff --git a/google/genai/_gaos/google_genai.py b/google/genai/_gaos/google_genai.py index d1d83e84a..dde0284e1 100644 --- a/google/genai/_gaos/google_genai.py +++ b/google/genai/_gaos/google_genai.py @@ -47,6 +47,7 @@ ) from .sdk import AsyncGenAI, GenAI from .types import environments +from .types import credentials from .types import interactions from .types.security import Security from .utils import BackoffStrategy, RetryConfig, eventstreaming @@ -56,6 +57,8 @@ from .webhooks import Webhooks as GeneratedWebhooks from .environments import AsyncEnvironments as GeneratedAsyncEnvironments from .environments import Environments as GeneratedEnvironments +from .credentials import AsyncCredentials as GeneratedAsyncCredentials +from .credentials import Credentials as GeneratedCredentials from .files import AsyncFiles as GeneratedAsyncFiles from .files import Files as GeneratedFiles @@ -611,6 +614,76 @@ async def ping(self, *args: Any, **kwargs: Any) -> Any: return await async_wrap_sdk_call(super().ping, *args, **kwargs) +class GeminiNextGenCredentials(GeneratedCredentials): + """Public credentials resource backed by the NextGen client. + + Subclasses the generated resource so every public method is wrapped in + `wrap_sdk_call`, translating per-operation `GenAiError` raises into the + status-code `APIError` hierarchy exposed at the + `google.genai._interactions` import surface. + """ + + def __init__(self, api_client: Any): + sdk = build_google_genai_client(api_client) + super().__init__(sdk.sdk_configuration, parent_ref=sdk) + + if not TYPE_CHECKING: + @property + def with_raw_response(self): + return _RawResponseAccessorProxy(super().with_raw_response) + + @property + def with_streaming_response(self): + return _RawResponseAccessorProxy(super().with_streaming_response) + + def create(self, *args: Any, **kwargs: Any) -> Any: + return wrap_sdk_call(super().create, *args, **kwargs) + + def list(self, *args: Any, **kwargs: Any) -> Any: + return wrap_sdk_call(super().list, *args, **kwargs) + + def get(self, *args: Any, **kwargs: Any) -> Any: + return wrap_sdk_call(super().get, *args, **kwargs) + + def update(self, *args: Any, **kwargs: Any) -> Any: + return wrap_sdk_call(super().update, *args, **kwargs) + + def delete(self, *args: Any, **kwargs: Any) -> Any: + return wrap_sdk_call(super().delete, *args, **kwargs) + + +class AsyncGeminiNextGenCredentials(GeneratedAsyncCredentials): + """Async public credentials resource backed by the NextGen client.""" + + def __init__(self, api_client: Any): + sdk = build_google_genai_async_client(api_client) + super().__init__(sdk.sdk_configuration, parent_ref=sdk) + + if not TYPE_CHECKING: + @property + def with_raw_response(self): + return _AsyncRawResponseAccessorProxy(super().with_raw_response) + + @property + def with_streaming_response(self): + return _AsyncRawResponseAccessorProxy(super().with_streaming_response) + + async def create(self, *args: Any, **kwargs: Any) -> Any: + return await async_wrap_sdk_call(super().create, *args, **kwargs) + + async def list(self, *args: Any, **kwargs: Any) -> Any: + return await async_wrap_sdk_call(super().list, *args, **kwargs) + + async def get(self, *args: Any, **kwargs: Any) -> Any: + return await async_wrap_sdk_call(super().get, *args, **kwargs) + + async def update(self, *args: Any, **kwargs: Any) -> Any: + return await async_wrap_sdk_call(super().update, *args, **kwargs) + + async def delete(self, *args: Any, **kwargs: Any) -> Any: + return await async_wrap_sdk_call(super().delete, *args, **kwargs) + + class GeminiNextGenAgents(GeneratedAgents): """Public agents resource backed by the NextGen client. diff --git a/google/genai/client.py b/google/genai/client.py index 8a939306c..c5b3fadcf 100644 --- a/google/genai/client.py +++ b/google/genai/client.py @@ -42,11 +42,13 @@ if TYPE_CHECKING: from ._gaos.google_genai import ( AsyncGeminiNextGenAgents, + AsyncGeminiNextGenCredentials, AsyncGeminiNextGenEnvironments, AsyncGeminiNextGenInteractions, AsyncGeminiNextGenTriggers, AsyncGeminiNextGenWebhooks, GeminiNextGenAgents, + GeminiNextGenCredentials, GeminiNextGenEnvironments, GeminiNextGenInteractions, GeminiNextGenTriggers, @@ -58,6 +60,7 @@ _agent_experimental_warned = False _trigger_experimental_warned = False _environment_experimental_warned = False +_credential_experimental_warned = False class AsyncClient: @@ -81,6 +84,7 @@ def __init__(self, api_client: BaseApiClient): self._webhooks: Optional[AsyncGeminiNextGenWebhooks] = None self._triggers: Optional[AsyncGeminiNextGenTriggers] = None self._environments: Optional[AsyncGeminiNextGenEnvironments] = None + self._credentials: Optional[AsyncGeminiNextGenCredentials] = None @property def _nextgen_client(self) -> AsyncGeminiNextGenAPI: @@ -157,6 +161,23 @@ def environments(self) -> AsyncGeminiNextGenEnvironments: self._environments = AsyncGeminiNextGenEnvironments(self._api_client) return self._environments + @property + def credentials(self) -> AsyncGeminiNextGenCredentials: + """Credentials resource.""" + global _credential_experimental_warned + if not _credential_experimental_warned: + _credential_experimental_warned = True + warnings.warn( + 'Credentials usage is experimental and may change in future versions.', + category=UserWarning, + stacklevel=1, + ) + if self._credentials is None: + from ._gaos.google_genai import AsyncGeminiNextGenCredentials + + self._credentials = AsyncGeminiNextGenCredentials(self._api_client) + return self._credentials + @property def models(self) -> AsyncModels: return self._models @@ -411,6 +432,7 @@ def __init__( self._webhooks: Optional[GeminiNextGenWebhooks] = None self._triggers: Optional[GeminiNextGenTriggers] = None self._environments: Optional[GeminiNextGenEnvironments] = None + self._credentials: Optional[GeminiNextGenCredentials] = None @staticmethod def _get_api_client( @@ -522,6 +544,22 @@ def environments(self) -> GeminiNextGenEnvironments: self._environments = GeminiNextGenEnvironments(self._api_client) return self._environments + @property + def credentials(self) -> GeminiNextGenCredentials: + global _credential_experimental_warned + if not _credential_experimental_warned: + _credential_experimental_warned = True + warnings.warn( + 'Credentials usage is experimental and may change in future versions.', + category=UserWarning, + stacklevel=2, + ) + if self._credentials is None: + from ._gaos.google_genai import GeminiNextGenCredentials + + self._credentials = GeminiNextGenCredentials(self._api_client) + return self._credentials + @property def chats(self) -> Chats: return Chats(modules=self.models) diff --git a/google/genai/credentials.py b/google/genai/credentials.py new file mode 100644 index 000000000..f6e1dd693 --- /dev/null +++ b/google/genai/credentials.py @@ -0,0 +1,31 @@ +# 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. +# +"""Expose Google GenAI credential types.""" + +from ._gaos.models.listcredentials import ListCredentialsRequestParam as CredentialListParams +from ._gaos.types.credentials.credential import Credential +from ._gaos.types.credentials.credentialcreateparams import CredentialCreateParams +from ._gaos.types.credentials.credentiallistresponse import CredentialListResponse +from ._gaos.types.credentials.credentialupdate import CredentialUpdateParam as CredentialUpdateParams +from ._gaos.types.interactions.empty import Empty as CredentialDeleteResponse + +__all__ = [ + "Credential", + "CredentialCreateParams", + "CredentialDeleteResponse", + "CredentialListParams", + "CredentialListResponse", + "CredentialUpdateParams", +] diff --git a/google/genai/tests/gaos/test_credentials_lifecycle.py b/google/genai/tests/gaos/test_credentials_lifecycle.py new file mode 100644 index 000000000..aef82a52d --- /dev/null +++ b/google/genai/tests/gaos/test_credentials_lifecycle.py @@ -0,0 +1,446 @@ +# Copyright 2026 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. +"""Lifecycle tests for Credentials API.""" + +from __future__ import annotations + +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import threading + +import pytest + +from ... import Client +from ..._gaos.models.getcredential import GetCredentialRequest +from ..._gaos.models.listcredentials import ListCredentialsRequest +from ..._gaos.types.credentials.credential import Credential +from ..._gaos.types.credentials.credentiallistresponse import ( + CredentialListResponse, +) +from ..._gaos.types.credentials.environmentvariableconfig import ( + EnvironmentVariableConfig, +) +from ..._gaos.types.credentials.environmentvariableupdateconfig import ( + EnvironmentVariableUpdateConfig, +) +from ..._gaos.types.credentials.httpbearerconfig import HTTPBearerConfig +from ..._gaos.types.credentials.httpbearerupdateconfig import ( + HTTPBearerUpdateConfig, +) +from ..._gaos.types.credentials.oauth2config import OAuth2Config +from ..._gaos.types.credentials.oauth2updateconfig import OAuth2UpdateConfig + +CREDENTIAL_BODY = { + "id": "cred_bearer_123", + "status": "active", + "type": "bearer_token", + "create_time": "2026-07-22T15:18:38Z", + "update_time": "2026-07-22T15:18:38Z", +} + +CREDENTIAL_LIST_BODY = { + "credentials": [ + CREDENTIAL_BODY, + { + "id": "cred_env_123", + "status": "active", + "type": "environment_variable", + "create_time": "2026-07-22T15:18:38Z", + "update_time": "2026-07-22T15:18:38Z", + }, + { + "id": "cred_oauth_123", + "status": "active", + "type": "oauth2", + "create_time": "2026-07-22T15:18:38Z", + "update_time": "2026-07-22T15:18:38Z", + }, + ], + "next_page_token": "token_next_123", +} + + +class _RecordingHandler(BaseHTTPRequestHandler): + captured: list[str] = [] + captured_bodies: list[dict] = [] + + def _record_and_respond(self) -> None: + self.captured.append(f"{self.command} {self.path}") + if self.command in ("POST", "PATCH", "PUT"): + content_length = int(self.headers.get("Content-Length", 0)) + if content_length > 0: + body = self.rfile.read(content_length) + self.captured_bodies.append(json.loads(body.decode("utf-8"))) + + if self.command == "GET" and ( + self.path == "/v1beta/credentials" + or self.path.startswith("/v1beta/credentials?") + ): + payload = json.dumps(CREDENTIAL_LIST_BODY).encode() + else: + payload = json.dumps(CREDENTIAL_BODY).encode() + + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + do_GET = _record_and_respond + do_POST = _record_and_respond + do_PATCH = _record_and_respond + do_DELETE = _record_and_respond + + def log_message(self, *args) -> None: + pass + + +def test_python_credentials_lifecycle_routes_through_google_genai_client( + monkeypatch, +): + monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False) + captured: list[str] = [] + captured_bodies: list[dict] = [] + handler = type("Handler", (_RecordingHandler,), { + "captured": captured, + "captured_bodies": captured_bodies, + }) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + client = Client( + api_key="test-api-key", + http_options={ + "api_version": "v1beta", + "base_url": f"http://127.0.0.1:{server.server_port}", + }, + ) + + # 1. Create Bearer Token Credential with header_name and prefix + bearer_cred = client.credentials.create( + id="cred_bearer_123", + token="test-bearer-token", + header_name="X-Custom-Auth", + prefix="Token", + type="bearer_token", + ) + assert bearer_cred.id == "cred_bearer_123" + + # 2. Create Environment Variable Credential with injection_location and trusted_domains + env_cred = client.credentials.create( + id="cred_env_123", + value="super-secret-key", + injection_location=["header", "query"], + trusted_domains=["api.example.com", "service.example.org"], + type="environment_variable", + ) + assert env_cred.id == "cred_bearer_123" + + # 3. Create OAuth2 Credential with scopes + oauth_cred = client.credentials.create( + id="cred_oauth_123", + client_id="test-client-id", + client_secret="test-client-secret", + refresh_token="test-refresh-token", + token_url="https://oauth2.googleapis.com/token", + scopes=["https://www.googleapis.com/auth/cloud-platform"], + type="oauth2", + ) + assert oauth_cred.id == "cred_bearer_123" + + # 4. List credentials + list_res = client.credentials.list() + assert list_res.credentials is not None + assert len(list_res.credentials) == 3 + + # 5. Get credential + fetched = client.credentials.get(id="cred_bearer_123") + assert fetched.id == "cred_bearer_123" + + # 6. Update Bearer Token Credential + client.credentials.update( + id="cred_bearer_123", + token="updated-bearer-token", + header_name="Authorization", + prefix="Bearer", + type="bearer_token", + ) + + # 7. Update Environment Variable Credential + client.credentials.update( + id="cred_env_123", + value="updated-secret", + injection_location="header", + trusted_domains=["api.example.com"], + type="environment_variable", + ) + + # 8. Update OAuth2 Credential + client.credentials.update( + id="cred_oauth_123", + client_secret="updated-secret", + scopes=["scope1", "scope2"], + type="oauth2", + ) + + # 9. Delete credential + client.credentials.delete(id="cred_bearer_123") + + assert captured == [ + "POST /v1beta/credentials", + "POST /v1beta/credentials", + "POST /v1beta/credentials", + "GET /v1beta/credentials", + "GET /v1beta/credentials/cred_bearer_123", + "PATCH /v1beta/credentials/cred_bearer_123", + "PATCH /v1beta/credentials/cred_env_123", + "PATCH /v1beta/credentials/cred_oauth_123", + "DELETE /v1beta/credentials/cred_bearer_123", + ] + + # Verify captured bodies + assert captured_bodies[0] == { + "id": "cred_bearer_123", + "token": "test-bearer-token", + "header_name": "X-Custom-Auth", + "prefix": "Token", + "type": "bearer_token", + } + assert captured_bodies[1] == { + "id": "cred_env_123", + "value": "super-secret-key", + "injection_location": ["header", "query"], + "trusted_domains": ["api.example.com", "service.example.org"], + "type": "environment_variable", + } + assert captured_bodies[2] == { + "id": "cred_oauth_123", + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "refresh_token": "test-refresh-token", + "token_url": "https://oauth2.googleapis.com/token", + "scopes": ["https://www.googleapis.com/auth/cloud-platform"], + "type": "oauth2", + } + + finally: + server.shutdown() + thread.join() + server.server_close() + + +@pytest.mark.asyncio +async def test_python_credentials_async_lifecycle(monkeypatch): + monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False) + captured: list[str] = [] + captured_bodies: list[dict] = [] + handler = type("Handler", (_RecordingHandler,), { + "captured": captured, + "captured_bodies": captured_bodies, + }) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + client = Client( + api_key="test-api-key", + http_options={ + "api_version": "v1beta", + "base_url": f"http://127.0.0.1:{server.server_port}", + }, + ) + + credential = await client.aio.credentials.create( + id="cred_env_123", + value="super-secret-key", + injection_location=["header", "query"], + trusted_domains=["api.example.com"], + type="environment_variable", + ) + list_res = await client.aio.credentials.list() + fetched = await client.aio.credentials.get(id="cred_env_123") + updated = await client.aio.credentials.update( + id="cred_env_123", + value="updated-secret", + injection_location="header", + type="environment_variable", + ) + await client.aio.credentials.delete(id="cred_env_123") + + assert credential.id == "cred_bearer_123" + assert fetched.id == "cred_bearer_123" + assert updated.id == "cred_bearer_123" + assert list_res.credentials is not None + assert len(list_res.credentials) == 3 + assert captured == [ + "POST /v1beta/credentials", + "GET /v1beta/credentials", + "GET /v1beta/credentials/cred_env_123", + "PATCH /v1beta/credentials/cred_env_123", + "DELETE /v1beta/credentials/cred_env_123", + ] + + finally: + server.shutdown() + thread.join() + server.server_close() + + +def test_python_credentials_with_raw_response(monkeypatch): + monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False) + captured: list[str] = [] + handler = type("Handler", (_RecordingHandler,), { + "captured": captured, + }) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + client = Client( + api_key="test-api-key", + http_options={ + "api_version": "v1beta", + "base_url": f"http://127.0.0.1:{server.server_port}", + }, + ) + + raw_res = client.credentials.with_raw_response.list() + parsed = raw_res.parse() + assert parsed.credentials is not None + assert len(parsed.credentials) == 3 + assert parsed.credentials[0].id == "cred_bearer_123" + + finally: + server.shutdown() + thread.join() + server.server_close() + + +def test_python_credentials_types_and_models(): + cred = Credential( + id="cred_123", + status="active", + type="bearer_token", + create_time="2026-07-22T15:18:38Z", + update_time="2026-07-22T15:18:38Z", + ) + assert cred.id == "cred_123" + assert cred.status == "active" + assert cred.type == "bearer_token" + assert cred.create_time is not None + assert cred.update_time is not None + + list_resp = CredentialListResponse( + credentials=[cred], + next_page_token="next_tok", + ) + assert list_resp.credentials is not None + assert len(list_resp.credentials) == 1 + assert list_resp.next_page_token == "next_tok" + + # HTTP Bearer Config + bearer = HTTPBearerConfig( + id="cred_bearer", + token="secret-token", + header_name="X-Auth", + prefix="Bearer", + ) + assert bearer.id == "cred_bearer" + assert bearer.token == "secret-token" + assert bearer.header_name == "X-Auth" + assert bearer.prefix == "Bearer" + assert bearer.type == "bearer_token" + + bearer_update = HTTPBearerUpdateConfig( + token="new-token", + header_name="Authorization", + prefix="Token", + ) + assert bearer_update.token == "new-token" + assert bearer_update.header_name == "Authorization" + assert bearer_update.prefix == "Token" + assert bearer_update.type == "bearer_token" + + # OAuth2 Config + oauth = OAuth2Config( + id="cred_oauth", + client_id="cid", + client_secret="csecret", + refresh_token="rtoken", + token_url="https://example.com/token", + scopes=["https://www.googleapis.com/auth/cloud-platform"], + ) + assert oauth.id == "cred_oauth" + assert oauth.client_id == "cid" + assert oauth.client_secret == "csecret" + assert oauth.refresh_token == "rtoken" + assert oauth.token_url == "https://example.com/token" + assert oauth.scopes == ["https://www.googleapis.com/auth/cloud-platform"] + assert oauth.type == "oauth2" + + oauth_update = OAuth2UpdateConfig( + client_secret="new-secret", + scopes=["scope1", "scope2"], + ) + assert oauth_update.client_secret == "new-secret" + assert oauth_update.scopes == ["scope1", "scope2"] + assert oauth_update.type == "oauth2" + + # Environment Variable Config with single and multiple injection locations + env_var = EnvironmentVariableConfig( + id="cred_env", + value="secret-key", + injection_location=["header", "query"], + trusted_domains=["api.example.com"], + ) + assert env_var.id == "cred_env" + assert env_var.value == "secret-key" + assert env_var.injection_location == ["header", "query"] + assert env_var.trusted_domains == ["api.example.com"] + assert env_var.type == "environment_variable" + + env_var_single = EnvironmentVariableConfig( + id="cred_env_2", + value="secret-key", + injection_location="header", + ) + assert env_var_single.injection_location == "header" + + env_var_update = EnvironmentVariableUpdateConfig( + value="updated-key", + injection_location="query", + trusted_domains=["new.example.com"], + ) + assert env_var_update.value == "updated-key" + assert env_var_update.injection_location == "query" + assert env_var_update.trusted_domains == ["new.example.com"] + assert env_var_update.type == "environment_variable" + + # Request models + req = GetCredentialRequest( + id="cred_123", + api_version="v1beta", + ) + assert req.id == "cred_123" + assert req.api_version == "v1beta" + + list_req = ListCredentialsRequest( + page_size=10, + page_token="tok", + api_version="v1beta", + ) + assert list_req.page_size == 10 + assert list_req.page_token == "tok" + assert list_req.api_version == "v1beta"