diff --git a/src/google/adk/auth/auth_credential.py b/src/google/adk/auth/auth_credential.py index 4a2add823c6..cdb1ace99d9 100644 --- a/src/google/adk/auth/auth_credential.py +++ b/src/google/adk/auth/auth_credential.py @@ -15,8 +15,10 @@ from __future__ import annotations from enum import Enum +from typing import Annotated from typing import Any from typing import Dict +from typing import Iterator from typing import List from typing import Literal @@ -26,22 +28,49 @@ from pydantic import Field from pydantic import model_validator +_REDACTED = "" + +# Pydantic echoes the rejected value into ValidationError messages +# ("input_value=..."), which would put a malformed secret straight into logs and +# into the error strings surfaced to the LLM. The field name and error type are +# still reported. class BaseModelWithConfig(BaseModel): + """Base model for credential types, hardened against leaking secrets.""" + model_config = ConfigDict( extra="allow", alias_generator=alias_generators.to_camel, populate_by_name=True, + hide_input_in_errors=True, ) """The pydantic model config.""" + def __repr_args__(self) -> Iterator[tuple[str | None, Any]]: + """Redacts the values of extra (unmodeled) fields from repr and str. + + `extra="allow"` lets callers attach arbitrary keys to these credential + models, and pydantic renders extras in repr unconditionally: marking a + declared field `repr=False` does nothing for a secret that arrives under an + unexpected key (e.g. a non-standard field in an OAuth2 token response). + Redacting the values keeps them out of logs and out of error strings that + reach the LLM, while still showing which keys were set. + + Yields: + `(name, value)` pairs to render, with the values of extra fields replaced + by a redaction placeholder. + """ + extra = self.__pydantic_extra__ or {} + for key, value in super().__repr_args__(): + yield key, _REDACTED if key in extra else value + class HttpCredentials(BaseModelWithConfig): """Represents the secret token value for HTTP authentication, like user name, password, oauth token, etc.""" username: str | None = None - password: str | None = None - token: str | None = None + password: Annotated[str | None, Field(repr=False)] = None + token: Annotated[str | None, Field(repr=False)] = None @classmethod def model_validate(cls, data: Dict[str, Any]) -> "HttpCredentials": @@ -61,14 +90,14 @@ class HttpAuth(BaseModelWithConfig): # Examples: 'basic', 'bearer' scheme: str credentials: HttpCredentials - additional_headers: Dict[str, str] | None = None + additional_headers: Annotated[Dict[str, str] | None, Field(repr=False)] = None class OAuth2Auth(BaseModelWithConfig): """Represents credential value and its metadata for a OAuth2 credential.""" client_id: str | None = None - client_secret: str | None = None + client_secret: Annotated[str | None, Field(repr=False)] = None # tool or adk can generate the auth_uri with the state info thus client # can verify the state auth_uri: str | None = None @@ -79,15 +108,15 @@ class OAuth2Auth(BaseModelWithConfig): state: str | None = None # tool or adk can decide the redirect_uri if they don't want client to decide redirect_uri: str | None = None - auth_response_uri: str | None = None - auth_code: str | None = None - access_token: str | None = None - refresh_token: str | None = None - id_token: str | None = None + auth_response_uri: Annotated[str | None, Field(repr=False)] = None + auth_code: Annotated[str | None, Field(repr=False)] = None + access_token: Annotated[str | None, Field(repr=False)] = None + refresh_token: Annotated[str | None, Field(repr=False)] = None + id_token: Annotated[str | None, Field(repr=False)] = None expires_at: int | None = None expires_in: int | None = None audience: str | None = None - code_verifier: str | None = None + code_verifier: Annotated[str | None, Field(repr=False)] = None code_challenge_method: str | None = None token_endpoint_auth_method: ( Literal[ @@ -140,8 +169,8 @@ class ServiceAccountCredential(BaseModelWithConfig): type_: str = Field("", alias="type") project_id: str - private_key_id: str - private_key: str + private_key_id: Annotated[str, Field(repr=False)] + private_key: Annotated[str, Field(repr=False)] client_email: str client_id: str auth_uri: str @@ -279,7 +308,7 @@ class AuthCredential(BaseModelWithConfig): # This will be supported in the future. resource_ref: str | None = None - api_key: str | None = None + api_key: Annotated[str | None, Field(repr=False)] = None http: HttpAuth | None = None service_account: ServiceAccount | None = None oauth2: OAuth2Auth | None = None diff --git a/src/google/adk/flows/llm_flows/base_llm_flow.py b/src/google/adk/flows/llm_flows/base_llm_flow.py index 40c47354f55..4dcd842f41b 100644 --- a/src/google/adk/flows/llm_flows/base_llm_flow.py +++ b/src/google/adk/flows/llm_flows/base_llm_flow.py @@ -528,10 +528,15 @@ async def run_live( return llm = self.__get_llm(invocation_context) + # Only log non-sensitive request metadata. The full request carries the + # user conversation and http_options.headers, which may hold credentials. logger.debug( - 'Establishing live connection for agent: %s with llm request: %s', + 'Establishing live connection for agent: %s, model: %s, contents: %s,' + ' response modalities: %s', invocation_context.agent.name, - llm_request, + llm_request.model, + len(llm_request.contents), + llm_request.live_connect_config.response_modalities, ) try: diff --git a/src/google/adk/models/google_llm.py b/src/google/adk/models/google_llm.py index afc6fb47504..cf4eece4b0e 100644 --- a/src/google/adk/models/google_llm.py +++ b/src/google/adk/models/google_llm.py @@ -464,8 +464,21 @@ async def connect(self, llm_request: LlmRequest) -> BaseLlmConnection: llm_request.live_connect_config.safety_settings = ( llm_request.config.safety_settings ) - logger.debug('Connecting to live with llm_request:%s', llm_request) - logger.debug('Live connect config: %s', llm_request.live_connect_config) + logger.debug( + 'Connecting to live with model: %s, contents: %d, response modalities:' + ' %s', + llm_request.model, + len(llm_request.contents or []), + llm_request.live_connect_config.response_modalities, + ) + # Callers may put credentials in per-request headers, so the transport + # options never go to the log. + logger.debug( + 'Live connect config: %s', + llm_request.live_connect_config.model_copy( + update={'http_options': None} + ), + ) async with self._live_api_client.aio.live.connect( model=llm_request.model, config=llm_request.live_connect_config ) as live_session: @@ -592,11 +605,14 @@ def _build_request_log(req: LlmRequest) -> str: exclude={ 'system_instruction': True, 'tools': tools_exclusion if req.config.tools else True, + # Callers may put credentials in per-request headers, so the + # transport options never go to the log. + 'http_options': True, }, ) ) except Exception: - config_log = repr(req.config) + config_log = repr(req.config.model_copy(update={'http_options': None})) return f""" LLM Request: diff --git a/src/google/adk/telemetry/tracing.py b/src/google/adk/telemetry/tracing.py index 32040b8bc6d..2b2c08b8353 100644 --- a/src/google/adk/telemetry/tracing.py +++ b/src/google/adk/telemetry/tracing.py @@ -360,7 +360,12 @@ def trace_call_llm( if _should_add_request_response_to_spans(): try: - llm_response_json = llm_response.model_dump_json(exclude_none=True) + response_for_trace = llm_response + if llm_response.content is not None: + response_for_trace = llm_response.model_copy( + update={'content': _summarize_inline_data(llm_response.content)} + ) + llm_response_json = response_for_trace.model_dump_json(exclude_none=True) except Exception: # pylint: disable=broad-exception-caught llm_response_json = '' @@ -409,6 +414,37 @@ def trace_call_llm( ) +def _summarize_inline_data(content: types.Content) -> types.Content: + """Returns ``content`` with inline binary parts reduced to a description. + + Serializing a part in JSON mode base64-encodes its ``inline_data``, so a + live session's audio chunks would otherwise be copied wholesale onto a span + attribute. Only the mime type and byte count are kept. + + Args: + content: The content to summarize. + + Returns: + A copy of ``content`` whose inline binary parts carry a text description + instead of the bytes. + """ + parts: list[types.Part] = [] + for part in content.parts or []: + blob = part.inline_data + if blob is None: + parts.append(part) + continue + parts.append( + types.Part( + text=( + f"" + ) + ) + ) + return types.Content(role=content.role, parts=parts) + + def trace_send_data( invocation_context: InvocationContext, event_id: str, @@ -435,7 +471,7 @@ def trace_send_data( span.set_attribute( 'gcp.vertex.agent.data', _safe_json_serialize([ - types.Content(role=content.role, parts=content.parts).model_dump( + _summarize_inline_data(content).model_dump( exclude_none=True, mode='json' ) for content in data @@ -514,7 +550,25 @@ def _build_llm_request_for_trace(llm_request: LlmRequest) -> dict[str, Any]: result = { 'model': llm_request.model, 'config': llm_request.config.model_dump( - exclude_none=True, exclude='response_schema', mode='json' + exclude_none=True, + exclude={ + 'response_schema': True, + # `http_options` carries caller-supplied credentials: `headers` + # commonly holds an Authorization bearer token, and + # `extra_body` / `*client_args` are free-form passthroughs that + # can hold auth material too. None of it may reach an exported + # span attribute. The client fields are also unserializable. + 'http_options': { + 'httpx_client': True, + 'httpx_async_client': True, + 'aiohttp_client': True, + 'headers': True, + 'extra_body': True, + 'client_args': True, + 'async_client_args': True, + }, + }, + mode='json', ), 'contents': [], } diff --git a/src/google/adk/tools/mcp_tool/mcp_tool.py b/src/google/adk/tools/mcp_tool/mcp_tool.py index 7c7a2bdd9f5..06de575638b 100644 --- a/src/google/adk/tools/mcp_tool/mcp_tool.py +++ b/src/google/adk/tools/mcp_tool/mcp_tool.py @@ -576,8 +576,7 @@ async def _get_headers( or not self._credentials_manager._auth_config ): error_msg = ( - "Cannot find corresponding auth scheme for API key credential" - f" {credential}" + "Cannot find corresponding auth scheme for API key credential." ) logger.error(error_msg) raise ValueError(error_msg) diff --git a/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py b/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py index fa32ce932af..3e4914c0243 100644 --- a/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py +++ b/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py @@ -564,8 +564,7 @@ def __repr__(self): return ( f'RestApiTool(name="{self.name}", description="{self.description}",' f' endpoint="{self.endpoint}", operation="{self.operation}",' - f' auth_scheme="{self.auth_scheme}",' - f' auth_credential="{self.auth_credential}")' + f' auth_scheme="{self.auth_scheme}")' ) diff --git a/tests/unittests/auth/test_auth_credential.py b/tests/unittests/auth/test_auth_credential.py new file mode 100644 index 00000000000..732a6ae6a51 --- /dev/null +++ b/tests/unittests/auth/test_auth_credential.py @@ -0,0 +1,199 @@ +# 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. + +"""Tests for the auth credential models and their shared base model.""" + +from __future__ import annotations + +from google.adk.auth.auth_credential import AuthCredential +from google.adk.auth.auth_credential import AuthCredentialTypes +from google.adk.auth.auth_credential import BaseModelWithConfig +from google.adk.auth.auth_credential import HttpAuth +from google.adk.auth.auth_credential import HttpCredentials +from google.adk.auth.auth_credential import OAuth2Auth +from google.adk.auth.auth_credential import ServiceAccountCredential +import pydantic +import pytest + + +class _Sample(BaseModelWithConfig): + access_token: str + + +def test_base_model_with_config_accepts_camel_case_alias(): + """Credentials arrive as JSON using the camelCase wire names.""" + model = _Sample.model_validate({'accessToken': 'abc'}) + assert model.access_token == 'abc' + + +def test_base_model_with_config_accepts_the_python_field_name(): + """Python callers construct with the snake_case field name.""" + model = _Sample(access_token='abc') + assert model.access_token == 'abc' + + +def test_base_model_with_config_keeps_unknown_fields(): + # Provider-specific keys are not modelled here, but dropping them would + # lose data on a load/dump round trip. + model = _Sample.model_validate({'accessToken': 'abc', 'tenantId': 'xyz'}) + assert model.model_dump()['tenantId'] == 'xyz' + + +def test_base_model_with_config_dumps_camel_case_only_when_asked(): + model = _Sample(access_token='abc') + assert model.model_dump()['access_token'] == 'abc' + assert model.model_dump(by_alias=True)['accessToken'] == 'abc' + + +def test_api_key_redacted_in_repr_and_str(): + """An API key is not rendered, but is still readable on the model.""" + cred = AuthCredential( + auth_type=AuthCredentialTypes.API_KEY, + api_key='sk-live-secret-api-key-12345', + ) + repr_str = repr(cred) + str_str = str(cred) + assert 'sk-live-secret-api-key-12345' not in repr_str + assert 'sk-live-secret-api-key-12345' not in str_str + # Only the rendering is redacted; the value itself is untouched. + assert cred.api_key == 'sk-live-secret-api-key-12345' + + +def test_http_credentials_redacted_in_repr_and_str(): + """HTTP passwords, tokens and auth headers are not rendered.""" + cred = AuthCredential( + auth_type=AuthCredentialTypes.HTTP, + http=HttpAuth( + scheme='basic', + credentials=HttpCredentials( + username='my_user', + password='secret_password_999', + token='secret_token_abc', + ), + additional_headers={'Authorization': 'Bearer secret_bearer_token'}, + ), + ) + repr_str = repr(cred) + str_str = str(cred) + assert 'secret_password_999' not in repr_str + assert 'secret_token_abc' not in repr_str + assert 'secret_bearer_token' not in repr_str + assert 'secret_password_999' not in str_str + assert 'secret_token_abc' not in str_str + + +def test_oauth2_credentials_redacted_in_repr_and_str(): + """OAuth2 secrets, tokens and the auth response URI are not rendered.""" + cred = AuthCredential( + auth_type=AuthCredentialTypes.OAUTH2, + oauth2=OAuth2Auth( + client_id='my_client_id', + client_secret='top_secret_client_secret', + access_token='secret_access_token', + refresh_token='secret_refresh_token', + id_token='secret_id_token', + auth_code='secret_auth_code', + auth_response_uri=( + 'https://example.com/callback?code=secret_response_code' + ), + code_verifier='secret_code_verifier', + ), + ) + repr_str = repr(cred) + str_str = str(cred) + assert 'top_secret_client_secret' not in repr_str + assert 'secret_access_token' not in repr_str + assert 'secret_refresh_token' not in repr_str + assert 'secret_id_token' not in repr_str + assert 'secret_auth_code' not in repr_str + assert 'secret_response_code' not in repr_str + assert 'secret_code_verifier' not in repr_str + assert 'top_secret_client_secret' not in str_str + assert 'secret_response_code' not in str_str + + +def test_service_account_redacted_in_repr_and_str(): + """A service account private key and its ID are not rendered.""" + sa_cred = ServiceAccountCredential( + type_='service_account', + project_id='test_project', + private_key_id='secret_private_key_id', + private_key=( + '-----BEGIN PRIVATE KEY-----\nsecret_key_data\n-----END PRIVATE' + ' KEY-----' + ), + client_email='test@iam.gserviceaccount.com', + client_id='12345', + auth_uri='https://example.com/o/oauth2/auth', + token_uri='https://example.com/token', + auth_provider_x509_cert_url='https://example.com/oauth2/v1/certs', + client_x509_cert_url='https://example.com/robot/v1/metadata/x509/test', + universe_domain='example.com', + ) + repr_str = repr(sa_cred) + str_str = str(sa_cred) + assert 'secret_key_data' not in repr_str + assert 'secret_private_key_id' not in repr_str + assert 'secret_key_data' not in str_str + assert 'secret_private_key_id' not in str_str + + +def test_extra_fields_redacted_in_repr_and_str(): + """A secret under an undeclared key is redacted, not rendered.""" + # `extra="allow"` means a secret can arrive under a key the model does not + # declare, which pydantic would otherwise render in repr unconditionally. + cred = AuthCredential.model_validate({ + 'auth_type': AuthCredentialTypes.API_KEY, + 'undeclared_secret': 'secret_extra_value', + }) + repr_str = repr(cred) + str_str = str(cred) + assert 'secret_extra_value' not in repr_str + assert 'secret_extra_value' not in str_str + # The key is still surfaced so the redaction is visible when debugging, and + # the value remains readable programmatically. + assert 'undeclared_secret' in repr_str + assert cred.undeclared_secret == 'secret_extra_value' + + +def test_nested_extra_fields_redacted_in_repr_and_str(): + """Undeclared keys on a nested credential model are redacted too.""" + # Mirrors an OAuth2 provider returning a non-standard token field. + cred = AuthCredential( + auth_type=AuthCredentialTypes.OAUTH2, + oauth2=OAuth2Auth.model_validate({ + 'client_id': 'my_client_id', + 'unexpected_token': 'secret_unexpected_token', + }), + ) + repr_str = repr(cred) + str_str = str(cred) + assert 'secret_unexpected_token' not in repr_str + assert 'secret_unexpected_token' not in str_str + assert 'my_client_id' in repr_str + + +def test_validation_error_does_not_echo_secret_value(): + """A rejected value is not echoed back in the ValidationError text.""" + # Pydantic reports the rejected value as `input_value=...` by default, which + # would put the secret into the error string surfaced to the LLM. + with pytest.raises(pydantic.ValidationError) as exc_info: + AuthCredential.model_validate({ + 'auth_type': AuthCredentialTypes.API_KEY, + 'api_key': ['sk-live-secret-api-key-12345'], + }) + message = str(exc_info.value) + assert 'sk-live-secret-api-key-12345' not in message + # The field and the reason are still reported. + assert 'api_key' in message diff --git a/tests/unittests/flows/llm_flows/test_base_llm_flow.py b/tests/unittests/flows/llm_flows/test_base_llm_flow.py index cb4de478d08..a7bcdfbf2a0 100644 --- a/tests/unittests/flows/llm_flows/test_base_llm_flow.py +++ b/tests/unittests/flows/llm_flows/test_base_llm_flow.py @@ -14,6 +14,7 @@ """Unit tests for BaseLlmFlow toolset integration.""" +import logging from typing import Optional from unittest import mock from unittest.mock import AsyncMock @@ -706,6 +707,69 @@ async def _mock_preprocess_with_history(ctx, req): yield +@pytest.mark.asyncio +async def test_run_live_does_not_log_http_options_headers(caplog): + """run_live must not log http_options headers, which can carry secrets.""" + + sentinel = 'do-not-log-this-live-credential' + # `flows/llm_flows/basic.py` deep-copies the agent's generate config onto + # `llm_request.config`, so this is how a caller's headers reach the flow. + agent = Agent( + name='test_agent', + model=Gemini(), + generate_content_config=types.GenerateContentConfig( + http_options=types.HttpOptions( + headers={'Authorization': f'Bearer {sentinel}'} + ) + ), + ) + invocation_context = await testing_utils.create_invocation_context( + agent=agent + ) + invocation_context.live_request_queue = LiveRequestQueue() + + flow = BaseLlmFlowForTesting() + + # We need a way to break the infinite loop in run_live for testing. + class StopError(Exception): + pass + + async def mock_receive(): + if False: # pylint: disable=using-constant-test + yield + raise StopError('stop') + + mock_connection = mock.AsyncMock() + mock_connection.receive = mock.Mock(side_effect=mock_receive) + + with caplog.at_level(logging.DEBUG, logger='google_adk'): + with mock.patch.object( + flow, '_preprocess_async', side_effect=_mock_preprocess_basic + ): + with mock.patch.object(flow, '_send_to_model', new_callable=AsyncMock): + with mock.patch( + 'google.adk.models.google_llm.Gemini.connect' + ) as mock_connect: + mock_connect.return_value.__aenter__.return_value = mock_connection + + try: + async for _ in flow.run_live(invocation_context): + pass + except StopError: + pass + + # The header reached the request the flow logged from, so the log line had + # access to it. + connect_request = mock_connect.call_args[0][0] + assert ( + connect_request.config.http_options.headers['Authorization'] + == f'Bearer {sentinel}' + ) + assert sentinel not in caplog.text + # The log line is still there and still useful. + assert 'Establishing live connection for agent: test_agent' in caplog.text + + @pytest.mark.asyncio async def test_run_live_resumes_from_run_config_handle(): """A caller-supplied RunConfig handle starts the session as a resumption.""" diff --git a/tests/unittests/models/test_google_llm.py b/tests/unittests/models/test_google_llm.py index 439b6d1468d..ab8dd7b3af5 100644 --- a/tests/unittests/models/test_google_llm.py +++ b/tests/unittests/models/test_google_llm.py @@ -2501,3 +2501,84 @@ async def mock_coro(): assert mock_build.called is should_call finally: gemini_logger.setLevel(original_level) + + +@pytest.mark.asyncio +async def test_generate_content_async_does_not_log_request_headers( + gemini_llm, llm_request, generate_content_response, caplog +): + """Custom headers can carry credentials, so they must stay out of the log.""" + sentinel = "sentinel-request-credential" + llm_request.config.http_options = types.HttpOptions( + headers={"Authorization": f"Bearer {sentinel}"} + ) + + with caplog.at_level(logging.DEBUG, logger="google_adk"): + with mock.patch.object(gemini_llm, "api_client") as mock_client: + + async def mock_coro(): + return generate_content_response + + mock_client.aio.models.generate_content.return_value = mock_coro() + + async for _ in gemini_llm.generate_content_async( + llm_request, stream=False + ): + pass + + assert sentinel not in caplog.text + # The header is still forwarded to the model API, only the log omits it. + config_arg = mock_client.aio.models.generate_content.call_args.kwargs[ + "config" + ] + assert ( + config_arg.http_options.headers["Authorization"] == f"Bearer {sentinel}" + ) + # The log is still emitted and still useful. + assert "LLM Request:" in caplog.text + assert "'temperature': 0.1" in caplog.text + + +@pytest.mark.asyncio +async def test_connect_does_not_log_request_headers( + gemini_llm, llm_request, caplog +): + """Custom headers can carry credentials, so they must stay out of the log.""" + sentinel = "sentinel-live-credential" + llm_request.config.http_options = types.HttpOptions( + headers={"Authorization": f"Bearer {sentinel}"} + ) + llm_request.live_connect_config = types.LiveConnectConfig( + response_modalities=[types.Modality.AUDIO], + http_options=types.HttpOptions( + headers={"Authorization": f"Bearer {sentinel}"} + ), + ) + + mock_live_session = mock.AsyncMock() + + with caplog.at_level(logging.DEBUG, logger="google_adk"): + with mock.patch.object(gemini_llm, "_live_api_client") as mock_live_client: + + class MockLiveConnect: + + async def __aenter__(self): + return mock_live_session + + async def __aexit__(self, *args): + pass + + mock_live_client.aio.live.connect.return_value = MockLiveConnect() + + async with gemini_llm.connect(llm_request): + pass + + assert sentinel not in caplog.text + # The header is still forwarded to the live API, only the log omits it. + config_arg = mock_live_client.aio.live.connect.call_args.kwargs["config"] + assert ( + config_arg.http_options.headers["Authorization"] == f"Bearer {sentinel}" + ) + # The log is still emitted and still useful. + assert "gemini-2.5-flash" in caplog.text + assert "Modality.AUDIO" in caplog.text diff --git a/tests/unittests/telemetry/test_spans.py b/tests/unittests/telemetry/test_spans.py index c0e4cc20b93..a5aad74f433 100644 --- a/tests/unittests/telemetry/test_spans.py +++ b/tests/unittests/telemetry/test_spans.py @@ -802,6 +802,123 @@ async def test_trace_send_data_disabling_request_response_content( ) +@pytest.mark.asyncio +async def test_trace_call_llm_summarizes_response_inline_data( + monkeypatch, mock_span_fixture +): + """Inline binary data in the response is described, not copied to the span.""" + monkeypatch.setattr( + 'opentelemetry.trace.get_current_span', lambda: mock_span_fixture + ) + + agent = LlmAgent(name='test_agent') + invocation_context = await _create_invocation_context(agent) + llm_request = LlmRequest( + model='gemini-pro', config=types.GenerateContentConfig() + ) + llm_response = LlmResponse( + content=types.Content( + role='model', + parts=[ + types.Part(text='hi'), + types.Part.from_bytes(data=b'test_data', mime_type='audio/pcm'), + ], + ) + ) + + trace_call_llm(invocation_context, 'test_event_id', llm_request, llm_response) + + llm_response_json = next( + call_obj.args[1] + for call_obj in mock_span_fixture.set_attribute.call_args_list + if call_obj.args[0] == 'gcp.vertex.agent.llm_response' + ) + + # b'test_data' base64-encodes to 'dGVzdF9kYXRh'. + assert 'dGVzdF9kYXRh' not in llm_response_json + assert 'hi' in llm_response_json + assert '' in llm_response_json + + +@pytest.mark.asyncio +async def test_trace_send_data_summarizes_inline_data( + monkeypatch, mock_span_fixture +): + """Inline binary data is described on the span, never copied onto it.""" + monkeypatch.setenv(ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS, 'true') + monkeypatch.setattr( + 'opentelemetry.trace.get_current_span', lambda: mock_span_fixture + ) + + agent = LlmAgent(name='test_agent') + invocation_context = await _create_invocation_context(agent) + + trace_send_data( + invocation_context=invocation_context, + event_id='test_event_id', + data=[ + types.Content( + role='user', + parts=[ + types.Part(text='hi'), + types.Part.from_bytes( + data=b'test_data', mime_type='audio/pcm' + ), + ], + ) + ], + ) + + data_json = next( + call_obj.args[1] + for call_obj in mock_span_fixture.set_attribute.call_args_list + if call_obj.args[0] == 'gcp.vertex.agent.data' + ) + + # b'test_data' base64-encodes to 'dGVzdF9kYXRh'. + assert 'dGVzdF9kYXRh' not in data_json + assert 'hi' in data_json + assert '' in data_json + + +@pytest.mark.asyncio +async def test_trace_send_data_summarizes_blob_without_mime_type( + monkeypatch, mock_span_fixture +): + """A blob is described even when its mime type and bytes are unset. + + The parts-less content in the same call pins that summarizing tolerates + ``Content.parts`` being unset. + """ + monkeypatch.setenv(ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS, 'true') + monkeypatch.setattr( + 'opentelemetry.trace.get_current_span', lambda: mock_span_fixture + ) + + agent = LlmAgent(name='test_agent') + invocation_context = await _create_invocation_context(agent) + + trace_send_data( + invocation_context=invocation_context, + event_id='test_event_id', + data=[ + types.Content(role='user'), + types.Content( + role='user', parts=[types.Part(inline_data=types.Blob())] + ), + ], + ) + + data_json = next( + call_obj.args[1] + for call_obj in mock_span_fixture.set_attribute.call_args_list + if call_obj.args[0] == 'gcp.vertex.agent.data' + ) + + assert '' in data_json + assert 'inlineData' not in data_json + + @pytest.mark.asyncio @mock.patch('google.adk.telemetry.tracing.otel_logger') @mock.patch('google.adk.telemetry.tracing.tracer') @@ -1387,6 +1504,68 @@ def test_trace_tool_call_with_standard_error( ) +def test_build_llm_request_for_trace_excludes_live_http_clients(): + """Tracing must not crash when config.http_options holds live SDK clients. + + HttpOptions.{httpx_client, httpx_async_client, aiohttp_client} are live + transport objects that pydantic cannot serialize; they must be excluded so + the trace serialization does not raise PydanticSerializationError. + """ + from google.adk.telemetry.tracing import _build_llm_request_for_trace + import httpx + + llm_request = LlmRequest( + model='gemini-2.0-flash', + config=types.GenerateContentConfig( + temperature=0.1, + http_options=types.HttpOptions( + httpx_async_client=httpx.AsyncClient() + ), + ), + ) + + result = _build_llm_request_for_trace(llm_request) + + # Must be JSON-serializable (raised PydanticSerializationError before the fix). + json.dumps(result) + assert 'httpx_async_client' not in result['config'].get('http_options', {}) + assert result['config']['temperature'] == 0.1 + + +def test_build_llm_request_for_trace_excludes_http_option_credentials(): + """Credential-bearing http_options fields must never reach a span attribute. + + `http_options` is a documented place for callers to put custom headers + (including `Authorization`), and the agent's generate config is copied onto + `llm_request.config`. Serializing it verbatim would export the caller's + credentials to the tracing backend on every model call. + """ + from google.adk.telemetry.tracing import _build_llm_request_for_trace + + llm_request = LlmRequest( + model='gemini-2.0-flash', + config=types.GenerateContentConfig( + temperature=0.1, + http_options=types.HttpOptions( + base_url='https://example.test', + headers={'Authorization': 'Bearer sentinel-secret-token'}, + extra_body={'api_key': 'sentinel-secret-token'}, + client_args={'auth': 'sentinel-secret-token'}, + async_client_args={'auth': 'sentinel-secret-token'}, + ), + ), + ) + + result = _build_llm_request_for_trace(llm_request) + + assert 'sentinel-secret-token' not in json.dumps(result) + http_options = result['config'].get('http_options', {}) + for field in ('headers', 'extra_body', 'client_args', 'async_client_args'): + assert field not in http_options + # Non-sensitive http_options fields are still traced. + assert http_options['base_url'] == 'https://example.test' + + def test_safe_json_serialize_circular_dict_returns_not_serializable(): obj = {} obj['self'] = obj diff --git a/tests/unittests/tools/mcp_tool/test_mcp_tool.py b/tests/unittests/tools/mcp_tool/test_mcp_tool.py index 6643547df94..a0d6ee90284 100644 --- a/tests/unittests/tools/mcp_tool/test_mcp_tool.py +++ b/tests/unittests/tools/mcp_tool/test_mcp_tool.py @@ -602,9 +602,11 @@ async def test_get_headers_api_key_without_auth_config_raises_error(self): with pytest.raises( ValueError, match="Cannot find corresponding auth scheme for API key credential", - ): + ) as exc_info: await tool._get_headers(tool_context, credential) + assert "my_api_key" not in str(exc_info.value) + @pytest.mark.asyncio async def test_get_headers_api_key_without_credentials_manager_raises_error( self, @@ -626,9 +628,11 @@ async def test_get_headers_api_key_without_credentials_manager_raises_error( with pytest.raises( ValueError, match="Cannot find corresponding auth scheme for API key credential", - ): + ) as exc_info: await tool._get_headers(tool_context, credential) + assert "my_api_key" not in str(exc_info.value) + @pytest.mark.asyncio async def test_get_headers_no_credential(self): """Test header generation with no credentials.""" diff --git a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py index fa212014886..e2feb0d011a 100644 --- a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py +++ b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py @@ -1495,6 +1495,31 @@ def test_prepare_request_params_plain_url_unchanged( assert request_params["url"] == "https://example.com/test" + def test_rest_api_tool_repr_and_str( + self, sample_endpoint, sample_operation, sample_auth_scheme + ): + """The attached credential is not rendered into repr or str.""" + secret_cred = AuthCredential( + auth_type=AuthCredentialTypes.API_KEY, + api_key="sk-live-secret-api-key-12345", + ) + tool = RestApiTool( + name="test_tool", + description="test description", + endpoint=sample_endpoint, + operation=sample_operation, + auth_scheme=sample_auth_scheme, + auth_credential=secret_cred, + ) + repr_str = repr(tool) + str_str = str(tool) + assert 'name="test_tool"' in repr_str + assert 'description="test description"' in repr_str + assert "auth_scheme=" in repr_str + assert "auth_credential=" not in repr_str + assert "sk-live-secret-api-key-12345" not in repr_str + assert "sk-live-secret-api-key-12345" not in str_str + def test_snake_to_lower_camel(): assert snake_to_lower_camel("single") == "single"