Skip to content

Commit faca170

Browse files
wyf7107copybara-github
authored andcommitted
feat: Support additional scopes and custom discovery doc in Google API Tools
Merge #5569 **Problem:** Users needed to be able to add additional scopes to `GoogleApiToolset` instances to avoid 401 errors when the auto-selected scope was insufficient. They also needed to be able to specify a custom discovery document URL for cases where the standard Google discovery service is not used or a specific version is needed. **Solution:** - Added `additional_scopes` parameter to `GoogleApiToolset` to allow appending scopes to the default one derived from the discovery document. - Added `discovery_url` parameter to `GoogleApiToolset` and `GoogleApiToOpenApiConverter` to allow specifying a custom discovery URL. GitOrigin-RevId: 104bcaa Change-Id: Iceb3835a42cbcc4e2672e735454da6f0166802bb Co-authored-by: Yifan Wang <wanyif@google.com> PiperOrigin-RevId: 927468647
1 parent 77356d8 commit faca170

5 files changed

Lines changed: 118 additions & 8 deletions

File tree

src/google/adk/tools/base_toolset.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ def from_config(
192192
raise ValueError(f"from_config() not implemented for toolset: {cls}")
193193

194194
def _is_tool_selected(
195-
self, tool: BaseTool, readonly_context: ReadonlyContext
195+
self, tool: BaseTool, readonly_context: Optional[ReadonlyContext]
196196
) -> bool:
197197
if not self.tool_filter:
198198
return True

src/google/adk/tools/google_api_tool/google_api_toolset.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ class GoogleApiToolset(BaseToolset):
4848
tool_name_prefix: Optional prefix to add to all tool names in this toolset.
4949
additional_headers: Optional dict of HTTP headers to inject into every request
5050
executed by this toolset.
51+
additional_scopes: Optional list of additional scopes to request.
52+
discovery_url: Optional custom discovery URL to use for the API.
5153
"""
5254

5355
def __init__(
@@ -61,6 +63,8 @@ def __init__(
6163
tool_name_prefix: Optional[str] = None,
6264
*,
6365
additional_headers: Optional[Dict[str, str]] = None,
66+
additional_scopes: Optional[List[str]] = None,
67+
discovery_url: Optional[str] = None,
6468
):
6569
super().__init__(tool_filter=tool_filter, tool_name_prefix=tool_name_prefix)
6670
self.api_name = api_name
@@ -69,6 +73,8 @@ def __init__(
6973
self._client_secret = client_secret
7074
self._service_account = service_account
7175
self._additional_headers = additional_headers
76+
self._additional_scopes = additional_scopes
77+
self._discovery_url = discovery_url
7278
self._openapi_toolset = self._load_toolset_with_oidc_auth()
7379

7480
@override
@@ -93,13 +99,22 @@ def set_tool_filter(self, tool_filter: Union[ToolPredicate, List[str]]):
9399

94100
def _load_toolset_with_oidc_auth(self) -> OpenAPIToolset:
95101
spec_dict = GoogleApiToOpenApiConverter(
96-
self.api_name, self.api_version
102+
self.api_name, self.api_version, discovery_url=self._discovery_url
97103
).convert()
98-
scope = list(
104+
discovery_scopes = list(
99105
spec_dict['components']['securitySchemes']['oauth2']['flows'][
100106
'authorizationCode'
101107
]['scopes'].keys()
102-
)[0]
108+
)
109+
default_scope = discovery_scopes[0] if discovery_scopes else None
110+
111+
scopes = list(
112+
dict.fromkeys(
113+
([default_scope] if default_scope else [])
114+
+ (self._additional_scopes or [])
115+
)
116+
)
117+
103118
return OpenAPIToolset(
104119
spec_dict=spec_dict,
105120
spec_str_type='yaml',
@@ -117,7 +132,7 @@ def _load_toolset_with_oidc_auth(self) -> OpenAPIToolset:
117132
'client_secret_basic',
118133
],
119134
grant_types_supported=['authorization_code'],
120-
scopes=[scope],
135+
scopes=scopes,
121136
),
122137
)
123138

src/google/adk/tools/google_api_tool/googleapi_to_openapi_converter.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,19 @@
3232
class GoogleApiToOpenApiConverter:
3333
"""Converts Google API Discovery documents to OpenAPI v3 format."""
3434

35-
def __init__(self, api_name: str, api_version: str):
35+
def __init__(
36+
self, api_name: str, api_version: str, *, discovery_url: str | None = None
37+
):
3638
"""Initialize the converter with the API name and version.
3739
3840
Args:
3941
api_name: The name of the Google API (e.g., "calendar")
4042
api_version: The version of the API (e.g., "v3")
43+
discovery_url: Optional custom discovery document URL.
4144
"""
4245
self._api_name = api_name
4346
self._api_version = api_version
47+
self._discovery_url = discovery_url
4448
self._google_api_resource = None
4549
self._google_api_spec = None
4650
self._openapi_spec = {
@@ -60,7 +64,14 @@ def fetch_google_api_spec(self) -> None:
6064
self._api_version,
6165
)
6266
# Build a resource object for the specified API
63-
self._google_api_resource = build(self._api_name, self._api_version)
67+
if self._discovery_url:
68+
self._google_api_resource = build(
69+
self._api_name,
70+
self._api_version,
71+
discoveryServiceUrl=self._discovery_url,
72+
)
73+
else:
74+
self._google_api_resource = build(self._api_name, self._api_version)
6475

6576
# Access the underlying API discovery document
6677
self._google_api_spec = self._google_api_resource._rootDesc

tests/unittests/tools/google_api_tool/test_google_api_toolset.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ def test_init(
146146
assert tool_set._additional_headers == additional_headers
147147

148148
mock_converter_class.assert_called_once_with(
149-
TEST_API_NAME, TEST_API_VERSION
149+
TEST_API_NAME, TEST_API_VERSION, discovery_url=None
150150
)
151151
mock_converter_instance.convert.assert_called_once()
152152
spec_dict = mock_converter_instance.convert.return_value
@@ -158,6 +158,69 @@ def test_init(
158158
assert isinstance(kwargs["auth_scheme"], OpenIdConnectWithConfig)
159159
assert kwargs["auth_scheme"].scopes == [DEFAULT_SCOPE]
160160

161+
@mock.patch(
162+
"google.adk.tools.google_api_tool.google_api_toolset.OpenAPIToolset"
163+
)
164+
@mock.patch(
165+
"google.adk.tools.google_api_tool.google_api_toolset.GoogleApiToOpenApiConverter"
166+
)
167+
def test_init_with_additional_scopes(
168+
self,
169+
mock_converter_class,
170+
mock_openapi_toolset_class,
171+
mock_converter_instance,
172+
mock_openapi_toolset_instance,
173+
):
174+
"""Test GoogleApiToolset initialization with additional scopes."""
175+
mock_converter_class.return_value = mock_converter_instance
176+
mock_openapi_toolset_class.return_value = mock_openapi_toolset_instance
177+
178+
extra_scopes = [
179+
DEFAULT_SCOPE,
180+
"https://www.googleapis.com/auth/calendar.readonly",
181+
]
182+
tool_set = GoogleApiToolset(
183+
api_name=TEST_API_NAME,
184+
api_version=TEST_API_VERSION,
185+
additional_scopes=extra_scopes,
186+
)
187+
188+
mock_openapi_toolset_class.assert_called_once()
189+
_, kwargs = mock_openapi_toolset_class.call_args
190+
assert isinstance(kwargs["auth_scheme"], OpenIdConnectWithConfig)
191+
assert kwargs["auth_scheme"].scopes == [
192+
DEFAULT_SCOPE,
193+
"https://www.googleapis.com/auth/calendar.readonly",
194+
]
195+
196+
@mock.patch(
197+
"google.adk.tools.google_api_tool.google_api_toolset.OpenAPIToolset"
198+
)
199+
@mock.patch(
200+
"google.adk.tools.google_api_tool.google_api_toolset.GoogleApiToOpenApiConverter"
201+
)
202+
def test_init_with_discovery_url(
203+
self,
204+
mock_converter_class,
205+
mock_openapi_toolset_class,
206+
mock_converter_instance,
207+
mock_openapi_toolset_instance,
208+
):
209+
"""Test GoogleApiToolset initialization with custom discovery URL."""
210+
mock_converter_class.return_value = mock_converter_instance
211+
mock_openapi_toolset_class.return_value = mock_openapi_toolset_instance
212+
213+
discovery_url = "https://example.com/discovery"
214+
tool_set = GoogleApiToolset(
215+
api_name=TEST_API_NAME,
216+
api_version=TEST_API_VERSION,
217+
discovery_url=discovery_url,
218+
)
219+
220+
mock_converter_class.assert_called_once_with(
221+
TEST_API_NAME, TEST_API_VERSION, discovery_url=discovery_url
222+
)
223+
161224
@mock.patch(
162225
"google.adk.tools.google_api_tool.google_api_toolset.GoogleApiTool"
163226
)

tests/unittests/tools/google_api_tool/test_googleapi_to_openapi_converter.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,27 @@ def test_fetch_google_api_spec(
261261
# Verify the results
262262
assert converter_with_patched_build._google_api_spec == calendar_api_spec
263263

264+
def test_fetch_google_api_spec_with_discovery_url(
265+
self, monkeypatch, mock_api_resource, calendar_api_spec
266+
):
267+
"""Test fetching Google API specification with custom discovery URL."""
268+
mock_build = MagicMock(return_value=mock_api_resource)
269+
monkeypatch.setattr(
270+
"google.adk.tools.google_api_tool.googleapi_to_openapi_converter.build",
271+
mock_build,
272+
)
273+
274+
discovery_url = "https://example.com/discovery"
275+
converter = GoogleApiToOpenApiConverter(
276+
"calendar", "v3", discovery_url=discovery_url
277+
)
278+
converter.fetch_google_api_spec()
279+
280+
assert converter._google_api_spec == calendar_api_spec
281+
mock_build.assert_called_once_with(
282+
"calendar", "v3", discoveryServiceUrl=discovery_url
283+
)
284+
264285
def test_fetch_google_api_spec_error(self, monkeypatch, converter):
265286
"""Test error handling when fetching Google API specification."""
266287
# Create a mock that raises an error

0 commit comments

Comments
 (0)