From c0f490546e20f787ae79e9aa74deebeca32f0ef5 Mon Sep 17 00:00:00 2001 From: George Weale Date: Mon, 17 Aug 2026 23:01:56 +0000 Subject: [PATCH 1/5] fix: Port credential redaction in AuthCredential repr and error messages to v1 Calling repr() or str() on a credential model, interpolating one into an f-string, or letting pydantic reject a malformed value rendered the secret in full: the API key, password, bearer token, OAuth access, refresh and id tokens, the PKCE verifier, and the service-account private key. Two tools made that worse by interpolating a whole credential into a message a caller sees, one in an McpTool ValueError and one in RestApiTool.__repr__. Now the secret fields are marked repr=False, so only the field names are rendered. The credential base model sets hide_input_in_errors, so a ValidationError reports the field name and the error type without echoing the rejected value. Extra keys, which extra="allow" lets a caller attach and which pydantic renders unconditionally, have their values replaced by a placeholder. The McpTool error message and the RestApiTool repr no longer mention the credential at all. Behaviour change: anyone who debugs by printing a credential object now sees the field name with no value. model_dump() and model_dump_json() are untouched, so the credential services keep round-tripping secrets correctly. --- src/google/adk/auth/auth_credential.py | 59 ++++-- src/google/adk/tools/mcp_tool/mcp_tool.py | 3 +- .../openapi_spec_parser/rest_api_tool.py | 3 +- tests/unittests/auth/test_auth_credential.py | 199 ++++++++++++++++++ .../unittests/tools/mcp_tool/test_mcp_tool.py | 8 +- .../openapi_spec_parser/test_rest_api_tool.py | 25 +++ 6 files changed, 277 insertions(+), 20 deletions(-) create mode 100644 tests/unittests/auth/test_auth_credential.py diff --git a/src/google/adk/auth/auth_credential.py b/src/google/adk/auth/auth_credential.py index 4a2add823c6..02316bea1a2 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,8 +28,19 @@ 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. Passed as a class keyword rather than added to `model_config` +# below: `model_config` states what these models accept, and rewriting that +# declaration reads as an API change to the breaking-change detector even though +# nothing about what they accept has changed. +class BaseModelWithConfig(BaseModel, hide_input_in_errors=True): + """Base model for credential types, hardened against leaking secrets.""" -class BaseModelWithConfig(BaseModel): model_config = ConfigDict( extra="allow", alias_generator=alias_generators.to_camel, @@ -35,13 +48,31 @@ class BaseModelWithConfig(BaseModel): ) """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 +92,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 +110,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 +171,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 +310,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/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/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" From 8c1ae1459ddd1c74f179fa0f1f644c934aa593b3 Mon Sep 17 00:00:00 2001 From: George Weale Date: Mon, 17 Aug 2026 23:03:18 +0000 Subject: [PATCH 2/5] fix: Port http_options exclusion from traced request config to v1 The traced request config was serialized with only response_schema excluded, so everything a caller put in GenerateContentConfig.http_options was exported to the tracing backend as a span attribute on every model call. That includes headers, which commonly carries an Authorization bearer token, and extra_body, client_args and async_client_args, which are free-form passthroughs callers use for auth material. Separately, a live httpx or aiohttp client passed through the same field made the serialization raise PydanticSerializationError, because those are transport objects pydantic cannot serialize. Now those seven http_options sub-fields are excluded from the dump. base_url and the rest of http_options are still traced, and the headers are still sent to the model API unchanged; only the span omits them. This combines two upstream fixes that edit the same expression, one excluding the live client objects and one excluding the credential-bearing fields. The intermediate state has no value on its own, so it is written here in its final form. Behaviour change: anyone reading traces loses the headers, extra_body, client_args and async_client_args sub-fields of http_options from the gcp.vertex.agent.llm_request attribute. --- src/google/adk/telemetry/tracing.py | 20 +++++++- tests/unittests/telemetry/test_spans.py | 62 +++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/google/adk/telemetry/tracing.py b/src/google/adk/telemetry/tracing.py index 32040b8bc6d..158bef1c308 100644 --- a/src/google/adk/telemetry/tracing.py +++ b/src/google/adk/telemetry/tracing.py @@ -514,7 +514,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/tests/unittests/telemetry/test_spans.py b/tests/unittests/telemetry/test_spans.py index c0e4cc20b93..c2fa6dfc1fc 100644 --- a/tests/unittests/telemetry/test_spans.py +++ b/tests/unittests/telemetry/test_spans.py @@ -1387,6 +1387,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 From dadeea18fb273f21d7dca4cb258d703b8028b25e Mon Sep 17 00:00:00 2001 From: George Weale Date: Mon, 17 Aug 2026 23:04:51 +0000 Subject: [PATCH 3/5] fix(telemetry): Port inline binary data summarization on spans to v1 Serializing a content part in JSON mode base64-encodes its inline_data, so the model response span and the trace_send_data span carried the bytes themselves. A live session's audio chunks and any image or document a user uploaded were copied wholesale onto an exported span attribute, which sent the payload to the trace backend and grew the span with it. Now a new private helper replaces every part that has inline_data with a text part reading "", and both call sites route their content through it. The request side already dropped inline parts and is unchanged. Behaviour change: anyone who was reading audio or image bytes back out of a span now gets that description string instead. The model still receives the real bytes; only the span is summarized. --- src/google/adk/telemetry/tracing.py | 40 +++++++- tests/unittests/telemetry/test_spans.py | 117 ++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 2 deletions(-) diff --git a/src/google/adk/telemetry/tracing.py b/src/google/adk/telemetry/tracing.py index 158bef1c308..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 diff --git a/tests/unittests/telemetry/test_spans.py b/tests/unittests/telemetry/test_spans.py index c2fa6dfc1fc..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') From d82cd795dd2b07abd5595417572413a927f7b830 Mon Sep 17 00:00:00 2001 From: George Weale Date: Mon, 17 Aug 2026 23:06:28 +0000 Subject: [PATCH 4/5] fix: Port omitting HTTP options from Gemini debug logs to v1 At DEBUG level the Gemini model wrote the request config to the log with only the system instruction and tools excluded, and on the live path it wrote the whole LlmRequest and the whole LiveConnectConfig. Credentials a caller put in GenerateContentConfig.http_options or LiveConnectConfig.http_options, most often an Authorization header, landed in the google_adk log on both paths. Now http_options is excluded from the request log and from the repr() used when the dump fails, and the live path logs the model, the content count and the response modalities instead of the whole request, with the live connect config copied without its http_options. Behaviour change: someone debugging at DEBUG level no longer gets the full llm_request dump on the live path. The headers are still sent to the model API and to the live API; only the log omits them. --- src/google/adk/models/google_llm.py | 22 +++++- tests/unittests/models/test_google_llm.py | 81 +++++++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) 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/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 From 3ed99cacd390dcc61f3fa668fccc1599d79fae61 Mon Sep 17 00:00:00 2001 From: George Weale Date: Mon, 17 Aug 2026 23:08:15 +0000 Subject: [PATCH 5/5] fix: Port dropping the full live LlmRequest from the base_llm_flow log to v1 Starting a live session wrote the entire LlmRequest into the debug log. That object holds the whole user conversation and config.http_options.headers, which is where a caller puts an Authorization token, so both ended up in the google_adk log at DEBUG level. Now the line logs the agent name, the model, the number of contents and the response modalities. The request itself is no longer interpolated. The regression test injects its sentinel credential through LlmAgent.generate_content_config, which the basic request processor deep-copies onto llm_request.config, and asserts the header did reach the request the flow connected with while staying out of the log. Behaviour change: debug-level output only. The replacement line keeps the fields anyone was realistically reading. --- src/google/adk/auth/auth_credential.py | 8 +-- .../adk/flows/llm_flows/base_llm_flow.py | 9 ++- .../flows/llm_flows/test_base_llm_flow.py | 64 +++++++++++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/google/adk/auth/auth_credential.py b/src/google/adk/auth/auth_credential.py index 02316bea1a2..cdb1ace99d9 100644 --- a/src/google/adk/auth/auth_credential.py +++ b/src/google/adk/auth/auth_credential.py @@ -34,17 +34,15 @@ # 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. Passed as a class keyword rather than added to `model_config` -# below: `model_config` states what these models accept, and rewriting that -# declaration reads as an API change to the breaking-change detector even though -# nothing about what they accept has changed. -class BaseModelWithConfig(BaseModel, hide_input_in_errors=True): +# 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.""" 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/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."""