From d79b22cd9c3e3f7e988cab375cf85c079c21d12b Mon Sep 17 00:00:00 2001 From: polar3130 Date: Thu, 5 Mar 2026 23:30:41 +0900 Subject: [PATCH 1/4] feat(apigee): add userinfo.email scope for tokeninfo user identification ApigeeLlm now explicitly requests the userinfo.email OAuth scope alongside cloud-platform when creating credentials. This enables Apigee Gateway to identify callers via Google's tokeninfo API when using Service Account key authentication. Fixes https://github.com/google/adk-python/issues/4721 --- src/google/adk/models/apigee_llm.py | 9 ++++++ tests/unittests/models/test_apigee_llm.py | 38 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/google/adk/models/apigee_llm.py b/src/google/adk/models/apigee_llm.py index 950c4baedb..0f19294001 100644 --- a/src/google/adk/models/apigee_llm.py +++ b/src/google/adk/models/apigee_llm.py @@ -29,6 +29,7 @@ from typing import Optional from typing import TYPE_CHECKING +import google.auth from google.adk import version as adk_version from google.genai import types import httpx @@ -52,6 +53,11 @@ _PROJECT_ENV_VARIABLE_NAME = 'GOOGLE_CLOUD_PROJECT' _LOCATION_ENV_VARIABLE_NAME = 'GOOGLE_CLOUD_LOCATION' +_APIGEE_SCOPES = [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', +] + _CUSTOM_METADATA_FIELDS = ( 'id', 'created', @@ -232,6 +238,8 @@ def api_client(self) -> Client: **kwargs_for_http_options, ) + credentials, _ = google.auth.default(scopes=_APIGEE_SCOPES) + kwargs_for_client = {} kwargs_for_client['vertexai'] = self._isvertexai if self._isvertexai: @@ -239,6 +247,7 @@ def api_client(self) -> Client: kwargs_for_client['location'] = self._location return Client( + credentials=credentials, http_options=http_options, **kwargs_for_client, ) diff --git a/tests/unittests/models/test_apigee_llm.py b/tests/unittests/models/test_apigee_llm.py index f48039fadd..a3fff45db0 100644 --- a/tests/unittests/models/test_apigee_llm.py +++ b/tests/unittests/models/test_apigee_llm.py @@ -18,6 +18,7 @@ from unittest import mock from unittest.mock import AsyncMock +from google.adk.models.apigee_llm import _APIGEE_SCOPES from google.adk.models.apigee_llm import ApigeeLlm from google.adk.models.apigee_llm import CompletionsHTTPClient from google.adk.models.llm_request import LlmRequest @@ -649,3 +650,40 @@ def test_parse_response_usage_metadata(): assert llm_response.usage_metadata.candidates_token_count == 5 assert llm_response.usage_metadata.total_token_count == 15 assert llm_response.usage_metadata.thoughts_token_count == 4 + + +@pytest.mark.asyncio +@mock.patch('google.genai.Client') +@mock.patch('google.adk.models.apigee_llm.google.auth.default') +async def test_api_client_requests_userinfo_email_scope( + mock_auth_default, mock_client_constructor, llm_request +): + """Tests that api_client requests userinfo.email scope for Apigee Gateway tokeninfo.""" + mock_credentials = mock.Mock() + mock_auth_default.return_value = (mock_credentials, 'test-project') + + mock_client_instance = mock.Mock() + mock_client_instance.aio.models.generate_content = AsyncMock( + return_value=types.GenerateContentResponse( + candidates=[ + types.Candidate( + content=Content( + parts=[Part.from_text(text='Test response')], + role='model', + ) + ) + ] + ) + ) + mock_client_constructor.return_value = mock_client_instance + + apigee_llm = ApigeeLlm( + model=APIGEE_GEMINI_MODEL_ID, + proxy_url=PROXY_URL, + ) + _ = [resp async for resp in apigee_llm.generate_content_async(llm_request)] + + mock_auth_default.assert_called_once_with(scopes=_APIGEE_SCOPES) + + _, kwargs = mock_client_constructor.call_args + assert kwargs['credentials'] is mock_credentials From 1b96b09f0d73c3282183239b4f8c73d6e141f826 Mon Sep 17 00:00:00 2001 From: polar3130 Date: Wed, 18 Mar 2026 11:09:56 +0900 Subject: [PATCH 2/4] style: fix import order in apigee_llm.py via autoformat.sh --- src/google/adk/models/apigee_llm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/google/adk/models/apigee_llm.py b/src/google/adk/models/apigee_llm.py index 0f19294001..748219ab7f 100644 --- a/src/google/adk/models/apigee_llm.py +++ b/src/google/adk/models/apigee_llm.py @@ -29,8 +29,8 @@ from typing import Optional from typing import TYPE_CHECKING -import google.auth from google.adk import version as adk_version +import google.auth from google.genai import types import httpx import tenacity From 0f0dc70e309971edf826d44e725d54f98eb4ef3b Mon Sep 17 00:00:00 2001 From: polar3130 Date: Sun, 22 Mar 2026 22:47:36 +0900 Subject: [PATCH 3/4] fix(apigee): mock google.auth.default in unit tests to fix CI failures Add autouse fixture to mock google.auth.default across all apigee_llm tests, preventing DefaultCredentialsError in CI environments without Application Default Credentials. --- tests/unittests/models/test_apigee_llm.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/unittests/models/test_apigee_llm.py b/tests/unittests/models/test_apigee_llm.py index a3fff45db0..b22f615b89 100644 --- a/tests/unittests/models/test_apigee_llm.py +++ b/tests/unittests/models/test_apigee_llm.py @@ -34,6 +34,16 @@ PROXY_URL = 'https://test.apigee.net' +@pytest.fixture(autouse=True) +def mock_google_auth_default(): + """Mocks google.auth.default to avoid requiring real credentials in tests.""" + with mock.patch( + 'google.adk.models.apigee_llm.google.auth.default' + ) as mock_auth: + mock_auth.return_value = (mock.Mock(), 'test-project') + yield mock_auth + + @pytest.fixture def llm_request(): """Provides a sample LlmRequest for testing.""" @@ -654,13 +664,12 @@ def test_parse_response_usage_metadata(): @pytest.mark.asyncio @mock.patch('google.genai.Client') -@mock.patch('google.adk.models.apigee_llm.google.auth.default') async def test_api_client_requests_userinfo_email_scope( - mock_auth_default, mock_client_constructor, llm_request + mock_client_constructor, llm_request, mock_google_auth_default ): """Tests that api_client requests userinfo.email scope for Apigee Gateway tokeninfo.""" mock_credentials = mock.Mock() - mock_auth_default.return_value = (mock_credentials, 'test-project') + mock_google_auth_default.return_value = (mock_credentials, 'test-project') mock_client_instance = mock.Mock() mock_client_instance.aio.models.generate_content = AsyncMock( @@ -683,7 +692,7 @@ async def test_api_client_requests_userinfo_email_scope( ) _ = [resp async for resp in apigee_llm.generate_content_async(llm_request)] - mock_auth_default.assert_called_once_with(scopes=_APIGEE_SCOPES) + mock_google_auth_default.assert_called_once_with(scopes=_APIGEE_SCOPES) _, kwargs = mock_client_constructor.call_args assert kwargs['credentials'] is mock_credentials From 7f0e22b67a7e5ff4e60bb1d0c6e24ae9ab12f48a Mon Sep 17 00:00:00 2001 From: polar3130 Date: Thu, 30 Apr 2026 09:43:21 +0900 Subject: [PATCH 4/4] refactor(apigee): replace scope-based auth with credentials injection Per reviewer feedback, drop the internal google.auth.default() call and the _APIGEE_SCOPES constant. Instead, expose an opt-in credentials parameter on ApigeeLlm.__init__ that is forwarded to genai.Client when provided. When omitted, the credentials kwarg is not forwarded at all, preserving the default genai.Client auth flow (and avoiding its Gemini Developer API warning about credentials usage). Callers needing additional OAuth scopes (e.g., userinfo.email for Apigee tokeninfo identification, the original #4721 use case) can now construct credentials with their preferred scopes and inject them. --- src/google/adk/models/apigee_llm.py | 19 +++++---- tests/unittests/models/test_apigee_llm.py | 52 +++++++++++++++-------- 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/src/google/adk/models/apigee_llm.py b/src/google/adk/models/apigee_llm.py index 519bfbddb9..2667ae8e00 100644 --- a/src/google/adk/models/apigee_llm.py +++ b/src/google/adk/models/apigee_llm.py @@ -30,7 +30,6 @@ from typing import TYPE_CHECKING from google.adk import version as adk_version -import google.auth from google.genai import types import httpx import tenacity @@ -41,6 +40,7 @@ from .llm_response import LlmResponse if TYPE_CHECKING: + from google.auth.credentials import Credentials from google.genai import Client from .llm_request import LlmRequest @@ -53,11 +53,6 @@ _PROJECT_ENV_VARIABLE_NAME = 'GOOGLE_CLOUD_PROJECT' _LOCATION_ENV_VARIABLE_NAME = 'GOOGLE_CLOUD_LOCATION' -_APIGEE_SCOPES = [ - 'https://www.googleapis.com/auth/cloud-platform', - 'https://www.googleapis.com/auth/userinfo.email', -] - _CUSTOM_METADATA_FIELDS = ( 'id', 'created', @@ -98,6 +93,7 @@ def __init__( custom_headers: dict[str, str] | None = None, retry_options: Optional[types.HttpRetryOptions] = None, api_type: ApiType | str = ApiType.UNKNOWN, + credentials: Optional[Credentials] = None, ): """Initializes the Apigee LLM backend. @@ -129,6 +125,11 @@ def __init__( authorization headers in Vertex AI and Gemini API calls. retry_options: Allow google-genai to retry failed responses. api_type: The type of API to use. One of `ApiType` or string. + credentials: Optional google-auth credentials passed through to the + underlying `genai.Client`. Use this when the Apigee proxy requires + additional OAuth scopes (e.g., `userinfo.email` for tokeninfo-based + caller identification). When omitted, the default `genai.Client` + authentication flow is used. """ # fmt: skip super().__init__(model=model, retry_options=retry_options) @@ -171,6 +172,7 @@ def __init__( ) self._custom_headers = custom_headers or {} self._user_agent = f'google-adk/{adk_version.__version__}' + self._credentials = credentials @classmethod @override @@ -240,16 +242,15 @@ def api_client(self) -> Client: **kwargs_for_http_options, ) - credentials, _ = google.auth.default(scopes=_APIGEE_SCOPES) - kwargs_for_client = {} kwargs_for_client['vertexai'] = self._isvertexai if self._isvertexai: kwargs_for_client['project'] = self._project kwargs_for_client['location'] = self._location + if self._credentials is not None: + kwargs_for_client['credentials'] = self._credentials return Client( - credentials=credentials, http_options=http_options, **kwargs_for_client, ) diff --git a/tests/unittests/models/test_apigee_llm.py b/tests/unittests/models/test_apigee_llm.py index 1a859e4ec6..1e371e8aa1 100644 --- a/tests/unittests/models/test_apigee_llm.py +++ b/tests/unittests/models/test_apigee_llm.py @@ -18,7 +18,6 @@ from unittest import mock from unittest.mock import AsyncMock -from google.adk.models.apigee_llm import _APIGEE_SCOPES from google.adk.models.apigee_llm import ApigeeLlm from google.adk.models.apigee_llm import CompletionsHTTPClient from google.adk.models.llm_request import LlmRequest @@ -34,16 +33,6 @@ PROXY_URL = 'https://test.apigee.net' -@pytest.fixture(autouse=True) -def mock_google_auth_default(): - """Mocks google.auth.default to avoid requiring real credentials in tests.""" - with mock.patch( - 'google.adk.models.apigee_llm.google.auth.default' - ) as mock_auth: - mock_auth.return_value = (mock.Mock(), 'test-project') - yield mock_auth - - @pytest.fixture def llm_request(): """Provides a sample LlmRequest for testing.""" @@ -664,12 +653,11 @@ def test_parse_response_usage_metadata(): @pytest.mark.asyncio @mock.patch('google.genai.Client') -async def test_api_client_requests_userinfo_email_scope( - mock_client_constructor, llm_request, mock_google_auth_default +async def test_api_client_passes_credentials_when_provided( + mock_client_constructor, llm_request ): - """Tests that api_client requests userinfo.email scope for Apigee Gateway tokeninfo.""" + """Tests that credentials passed to __init__ are forwarded to genai.Client.""" mock_credentials = mock.Mock() - mock_google_auth_default.return_value = (mock_credentials, 'test-project') mock_client_instance = mock.Mock() mock_client_instance.aio.models.generate_content = AsyncMock( @@ -689,15 +677,45 @@ async def test_api_client_requests_userinfo_email_scope( apigee_llm = ApigeeLlm( model=APIGEE_GEMINI_MODEL_ID, proxy_url=PROXY_URL, + credentials=mock_credentials, ) _ = [resp async for resp in apigee_llm.generate_content_async(llm_request)] - mock_google_auth_default.assert_called_once_with(scopes=_APIGEE_SCOPES) - _, kwargs = mock_client_constructor.call_args assert kwargs['credentials'] is mock_credentials +@pytest.mark.asyncio +@mock.patch('google.genai.Client') +async def test_api_client_omits_credentials_when_not_provided( + mock_client_constructor, llm_request +): + """Tests that credentials kwarg is not forwarded when not supplied.""" + mock_client_instance = mock.Mock() + mock_client_instance.aio.models.generate_content = AsyncMock( + return_value=types.GenerateContentResponse( + candidates=[ + types.Candidate( + content=Content( + parts=[Part.from_text(text='Test response')], + role='model', + ) + ) + ] + ) + ) + mock_client_constructor.return_value = mock_client_instance + + apigee_llm = ApigeeLlm( + model=APIGEE_GEMINI_MODEL_ID, + proxy_url=PROXY_URL, + ) + _ = [resp async for resp in apigee_llm.generate_content_async(llm_request)] + + _, kwargs = mock_client_constructor.call_args + assert 'credentials' not in kwargs + + def test_parse_response_with_refusal(): """Tests that CompletionsHTTPClient parses refusal correctly.""" client = CompletionsHTTPClient(base_url='http://test')