Skip to content

Commit c1b95bf

Browse files
authored
Merge branch 'main' into fix/handle-circular-refs
2 parents 472afa9 + 7be90db commit c1b95bf

File tree

7 files changed

+610
-111
lines changed

7 files changed

+610
-111
lines changed

src/google/adk/auth/auth_credential.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from pydantic import BaseModel
2626
from pydantic import ConfigDict
2727
from pydantic import Field
28+
from pydantic import model_validator
2829

2930

3031
class BaseModelWithConfig(BaseModel):
@@ -145,11 +146,45 @@ class ServiceAccountCredential(BaseModelWithConfig):
145146

146147

147148
class ServiceAccount(BaseModelWithConfig):
148-
"""Represents Google Service Account configuration."""
149+
"""Represents Google Service Account configuration.
150+
151+
Attributes:
152+
service_account_credential: The service account credential (JSON key).
153+
scopes: The OAuth2 scopes to request. Optional; when omitted with
154+
``use_default_credential=True``, defaults to the cloud-platform scope.
155+
use_default_credential: Whether to use Application Default Credentials.
156+
use_id_token: Whether to exchange for an ID token instead of an access
157+
token. Required for service-to-service authentication with Cloud Run,
158+
Cloud Functions, and other Google Cloud services that require identity
159+
verification. When True, ``audience`` must also be set.
160+
audience: The target audience for the ID token, typically the URL of the
161+
receiving service (e.g. ``https://my-service-xyz.run.app``). Required
162+
when ``use_id_token`` is True.
163+
"""
149164

150165
service_account_credential: Optional[ServiceAccountCredential] = None
151-
scopes: List[str]
166+
scopes: Optional[List[str]] = None
152167
use_default_credential: Optional[bool] = False
168+
use_id_token: Optional[bool] = False
169+
audience: Optional[str] = None
170+
171+
@model_validator(mode="after")
172+
def _validate_config(self) -> ServiceAccount:
173+
if (
174+
not self.use_default_credential
175+
and self.service_account_credential is None
176+
):
177+
raise ValueError(
178+
"service_account_credential is required when"
179+
" use_default_credential is False."
180+
)
181+
if self.use_id_token and not self.audience:
182+
raise ValueError(
183+
"audience is required when use_id_token is True. Set it to the"
184+
" URL of the target service"
185+
" (e.g. 'https://my-service.run.app')."
186+
)
187+
return self
153188

154189

155190
class AuthCredentialTypes(str, Enum):

src/google/adk/errors/session_not_found_error.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,11 @@
1414

1515
from __future__ import annotations
1616

17-
from .not_found_error import NotFoundError
1817

19-
20-
class SessionNotFoundError(ValueError, NotFoundError):
18+
class SessionNotFoundError(ValueError):
2119
"""Raised when a session cannot be found.
2220
23-
Inherits from both ValueError (for backward compatibility) and NotFoundError
24-
(for semantic consistency with the project's error hierarchy).
21+
Inherits from ValueError (for backward compatibility).
2522
"""
2623

2724
def __init__(self, message="Session not found."):

src/google/adk/models/google_llm.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,23 @@ class Gemini(BaseLlm):
8585
8686
Attributes:
8787
model: The name of the Gemini model.
88+
client: An optional preconfigured ``google.genai.Client`` instance.
89+
When provided, ADK uses this client for all API calls instead of
90+
creating one internally from environment variables or ADC. This
91+
allows fine-grained control over authentication, project, location,
92+
and other client-level settings — and enables running agents that
93+
target different Vertex AI regions within the same process.
94+
95+
Example::
96+
97+
from google import genai
98+
from google.adk.models import Gemini
99+
100+
client = genai.Client(
101+
vertexai=True, project="my-project", location="us-central1"
102+
)
103+
model = Gemini(model="gemini-2.5-flash", client=client)
104+
88105
use_interactions_api: Whether to use the interactions API for model
89106
invocation.
90107
"""
@@ -131,6 +148,35 @@ class Gemini(BaseLlm):
131148
```
132149
"""
133150

151+
def __init__(self, *, client: Optional[Client] = None, **kwargs: Any):
152+
"""Initialises a Gemini model wrapper.
153+
154+
Args:
155+
client: An optional preconfigured ``google.genai.Client``. When
156+
provided, ADK uses this client for **all** Gemini API calls
157+
(including the Live API) instead of creating one internally.
158+
159+
.. note::
160+
When a custom client is supplied it is used as-is for Live API
161+
connections. ADK will **not** override the client's
162+
``api_version``; you are responsible for setting the correct
163+
version (``v1beta1`` for Vertex AI, ``v1alpha`` for the
164+
Gemini developer API) on the client yourself.
165+
166+
.. warning::
167+
``google.genai.Client`` contains threading primitives that
168+
cannot be pickled. If you are deploying to Agent Engine (or
169+
any environment that serialises the model), do **not** pass a
170+
custom client — let ADK create one from the environment
171+
instead.
172+
173+
**kwargs: Forwarded to the Pydantic ``BaseLlm`` constructor
174+
(``model``, ``base_url``, ``retry_options``, etc.).
175+
"""
176+
super().__init__(**kwargs)
177+
# Store after super().__init__ so Pydantic validation runs first.
178+
object.__setattr__(self, '_client', client)
179+
134180
@classmethod
135181
@override
136182
def supported_models(cls) -> list[str]:
@@ -299,9 +345,16 @@ async def _generate_content_via_interactions(
299345
def api_client(self) -> Client:
300346
"""Provides the api client.
301347
348+
If a preconfigured ``client`` was passed to the constructor it is
349+
returned directly; otherwise a new ``Client`` is created using the
350+
default environment/ADC configuration.
351+
302352
Returns:
303353
The api client.
304354
"""
355+
if self._client is not None:
356+
return self._client
357+
305358
from google.genai import Client
306359

307360
return Client(
@@ -334,6 +387,9 @@ def _live_api_version(self) -> str:
334387

335388
@cached_property
336389
def _live_api_client(self) -> Client:
390+
if self._client is not None:
391+
return self._client
392+
337393
from google.genai import Client
338394

339395
return Client(

src/google/adk/tools/openapi_tool/auth/credential_exchangers/service_account_exchanger.py

Lines changed: 111 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from typing import Optional
2020

2121
import google.auth
22+
from google.auth import exceptions as google_auth_exceptions
2223
from google.auth.transport.requests import Request
2324
from google.oauth2 import service_account
2425
import google.oauth2.credentials
@@ -27,6 +28,7 @@
2728
from .....auth.auth_credential import AuthCredentialTypes
2829
from .....auth.auth_credential import HttpAuth
2930
from .....auth.auth_credential import HttpCredentials
31+
from .....auth.auth_credential import ServiceAccount
3032
from .....auth.auth_schemes import AuthScheme
3133
from .base_credential_exchanger import AuthCredentialMissingError
3234
from .base_credential_exchanger import BaseAuthCredentialExchanger
@@ -38,59 +40,142 @@ class ServiceAccountCredentialExchanger(BaseAuthCredentialExchanger):
3840
Uses the default service credential if `use_default_credential = True`.
3941
Otherwise, uses the service account credential provided in the auth
4042
credential.
43+
44+
Supports exchanging for either an access token (default) or an ID token
45+
when ``ServiceAccount.use_id_token`` is True. ID tokens are required for
46+
service-to-service authentication with Cloud Run, Cloud Functions, and
47+
other services that verify caller identity.
4148
"""
4249

4350
def exchange_credential(
4451
self,
4552
auth_scheme: AuthScheme,
4653
auth_credential: Optional[AuthCredential] = None,
4754
) -> AuthCredential:
48-
"""Exchanges the service account auth credential for an access token.
55+
"""Exchanges the service account auth credential for a token.
4956
5057
If auth_credential contains a service account credential, it will be used
51-
to fetch an access token. Otherwise, the default service credential will be
52-
used for fetching an access token.
58+
to fetch a token. Otherwise, the default service credential will be
59+
used for fetching a token.
60+
61+
When ``service_account.use_id_token`` is True, an ID token is fetched
62+
using the configured ``audience``. This is required for authenticating
63+
to Cloud Run, Cloud Functions, and similar services.
5364
5465
Args:
5566
auth_scheme: The auth scheme.
5667
auth_credential: The auth credential.
5768
5869
Returns:
59-
An AuthCredential in HTTPBearer format, containing the access token.
70+
An AuthCredential in HTTPBearer format, containing the token.
6071
"""
61-
if (
62-
auth_credential is None
63-
or auth_credential.service_account is None
64-
or (
65-
auth_credential.service_account.service_account_credential is None
66-
and not auth_credential.service_account.use_default_credential
67-
)
68-
):
72+
if auth_credential is None or auth_credential.service_account is None:
6973
raise AuthCredentialMissingError(
70-
"Service account credentials are missing. Please provide them, or set"
71-
" `use_default_credential = True` to use application default"
74+
"Service account credentials are missing. Please provide them, or"
75+
" set `use_default_credential = True` to use application default"
7276
" credential in a hosted service like Cloud Run."
7377
)
7478

79+
sa_config = auth_credential.service_account
80+
81+
if sa_config.use_id_token:
82+
return self._exchange_for_id_token(sa_config)
83+
84+
return self._exchange_for_access_token(sa_config)
85+
86+
def _exchange_for_id_token(self, sa_config: ServiceAccount) -> AuthCredential:
87+
"""Exchanges the service account credential for an ID token.
88+
89+
Args:
90+
sa_config: The service account configuration.
91+
92+
Returns:
93+
An AuthCredential in HTTPBearer format containing the ID token.
94+
95+
Raises:
96+
AuthCredentialMissingError: If token exchange fails.
97+
"""
98+
# audience and credential presence are validated by the ServiceAccount
99+
# model_validator at construction time.
75100
try:
76-
if auth_credential.service_account.use_default_credential:
77-
credentials, project_id = google.auth.default(
78-
scopes=["https://www.googleapis.com/auth/cloud-platform"],
101+
if sa_config.use_default_credential:
102+
from google.oauth2 import id_token as oauth2_id_token
103+
104+
request = Request()
105+
token = oauth2_id_token.fetch_id_token(request, sa_config.audience)
106+
else:
107+
# Guaranteed non-None by ServiceAccount model_validator.
108+
assert sa_config.service_account_credential is not None
109+
credentials = (
110+
service_account.IDTokenCredentials.from_service_account_info(
111+
sa_config.service_account_credential.model_dump(),
112+
target_audience=sa_config.audience,
113+
)
79114
)
80-
quota_project_id = (
81-
getattr(credentials, "quota_project_id", None) or project_id
115+
credentials.refresh(Request())
116+
token = credentials.token
117+
118+
return AuthCredential(
119+
auth_type=AuthCredentialTypes.HTTP,
120+
http=HttpAuth(
121+
scheme="bearer",
122+
credentials=HttpCredentials(token=token),
123+
),
124+
)
125+
126+
# ValueError is raised by google-auth when service account JSON is
127+
# missing required fields (e.g. client_email, private_key), or when
128+
# fetch_id_token cannot determine credentials from the environment.
129+
except (google_auth_exceptions.GoogleAuthError, ValueError) as e:
130+
raise AuthCredentialMissingError(
131+
f"Failed to exchange service account for ID token: {e}"
132+
) from e
133+
134+
def _exchange_for_access_token(
135+
self, sa_config: ServiceAccount
136+
) -> AuthCredential:
137+
"""Exchanges the service account credential for an access token.
138+
139+
Args:
140+
sa_config: The service account configuration.
141+
142+
Returns:
143+
An AuthCredential in HTTPBearer format containing the access token.
144+
145+
Raises:
146+
AuthCredentialMissingError: If scopes are missing for explicit
147+
credentials or token exchange fails.
148+
"""
149+
if not sa_config.use_default_credential and not sa_config.scopes:
150+
raise AuthCredentialMissingError(
151+
"scopes are required when using explicit service account credentials"
152+
" for access token exchange."
153+
)
154+
155+
try:
156+
if sa_config.use_default_credential:
157+
scopes = (
158+
sa_config.scopes
159+
if sa_config.scopes
160+
else ["https://www.googleapis.com/auth/cloud-platform"]
161+
)
162+
credentials, project_id = google.auth.default(
163+
scopes=scopes,
82164
)
165+
quota_project_id = credentials.quota_project_id or project_id
83166
else:
84-
config = auth_credential.service_account
167+
# Guaranteed non-None by ServiceAccount model_validator.
168+
assert sa_config.service_account_credential is not None
85169
credentials = service_account.Credentials.from_service_account_info(
86-
config.service_account_credential.model_dump(), scopes=config.scopes
170+
sa_config.service_account_credential.model_dump(),
171+
scopes=sa_config.scopes,
87172
)
88173
quota_project_id = None
89174

90175
credentials.refresh(Request())
91176

92-
updated_credential = AuthCredential(
93-
auth_type=AuthCredentialTypes.HTTP, # Store as a bearer token
177+
return AuthCredential(
178+
auth_type=AuthCredentialTypes.HTTP,
94179
http=HttpAuth(
95180
scheme="bearer",
96181
credentials=HttpCredentials(token=credentials.token),
@@ -101,9 +186,10 @@ def exchange_credential(
101186
else None,
102187
),
103188
)
104-
return updated_credential
105189

106-
except Exception as e:
190+
# ValueError is raised by google-auth when service account JSON is
191+
# missing required fields (e.g. client_email, private_key).
192+
except (google_auth_exceptions.GoogleAuthError, ValueError) as e:
107193
raise AuthCredentialMissingError(
108194
f"Failed to exchange service account token: {e}"
109195
) from e

0 commit comments

Comments
 (0)