Skip to content

Commit b318eee

Browse files
GWealecopybara-github
authored andcommitted
fix: Store and retrieve usage_metadata in Vertex AI custom_metadata
The Vertex AI session service does not natively support persisting usage_metadata. This change serializes usage_metadata into the custom_metadata field under the key '_usage_metadata' when appending events and deserializes it back when retrieving events. This allows usage information to be round-tripped through the Vertex AI session service. Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 885121070
1 parent 28618a8 commit b318eee

File tree

2 files changed

+178
-8
lines changed

2 files changed

+178
-8
lines changed

src/google/adk/sessions/vertex_ai_session_service.py

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@
4242

4343
logger = logging.getLogger('google_adk.' + __name__)
4444

45+
_COMPACTION_CUSTOM_METADATA_KEY = '_compaction'
46+
_USAGE_METADATA_CUSTOM_METADATA_KEY = '_usage_metadata'
47+
48+
49+
def _set_internal_custom_metadata(
50+
metadata_dict: dict[str, Any], *, key: str, value: dict[str, Any]
51+
) -> None:
52+
"""Stores internal metadata alongside user-provided custom metadata."""
53+
existing_custom_metadata = metadata_dict.get('custom_metadata') or {}
54+
metadata_dict['custom_metadata'] = {
55+
**existing_custom_metadata,
56+
key: value,
57+
}
58+
4559

4660
class VertexAiSessionService(BaseSessionService):
4761
"""Connects to the Vertex AI Agent Engine Session Service using Agent Engine SDK.
@@ -301,11 +315,22 @@ async def append_event(self, session: Session, event: Event) -> Event:
301315
compaction_dict = event.actions.compaction.model_dump(
302316
exclude_none=True, mode='json'
303317
)
304-
existing_custom = metadata_dict.get('custom_metadata') or {}
305-
metadata_dict['custom_metadata'] = {
306-
**existing_custom,
307-
'_compaction': compaction_dict,
308-
}
318+
_set_internal_custom_metadata(
319+
metadata_dict,
320+
key=_COMPACTION_CUSTOM_METADATA_KEY,
321+
value=compaction_dict,
322+
)
323+
# Store usage_metadata in custom_metadata since the Vertex AI service
324+
# does not persist it in EventMetadata.
325+
if event.usage_metadata:
326+
usage_dict = event.usage_metadata.model_dump(
327+
exclude_none=True, mode='json'
328+
)
329+
_set_internal_custom_metadata(
330+
metadata_dict,
331+
key=_USAGE_METADATA_CUSTOM_METADATA_KEY,
332+
value=usage_dict,
333+
)
309334
config['event_metadata'] = metadata_dict
310335

311336
async with self._get_api_client() as api_client:
@@ -378,11 +403,20 @@ def _from_api_event(api_event_obj: vertexai.types.SessionEvent) -> Event:
378403
# Extract compaction data stored in custom_metadata.
379404
# NOTE: This read path must be kept permanently because sessions
380405
# written before native compaction support store compaction data
381-
# in custom_metadata under the '_compaction' key.
406+
# in custom_metadata under the compaction metadata key.
382407
compaction_data = None
383-
if custom_metadata and '_compaction' in custom_metadata:
408+
usage_metadata_data = None
409+
if custom_metadata and (
410+
_COMPACTION_CUSTOM_METADATA_KEY in custom_metadata
411+
or _USAGE_METADATA_CUSTOM_METADATA_KEY in custom_metadata
412+
):
384413
custom_metadata = dict(custom_metadata) # avoid mutating the API response
385-
compaction_data = custom_metadata.pop('_compaction')
414+
compaction_data = custom_metadata.pop(
415+
_COMPACTION_CUSTOM_METADATA_KEY, None
416+
)
417+
usage_metadata_data = custom_metadata.pop(
418+
_USAGE_METADATA_CUSTOM_METADATA_KEY, None
419+
)
386420
if not custom_metadata:
387421
custom_metadata = None
388422
grounding_metadata = _session_util.decode_model(
@@ -397,6 +431,7 @@ def _from_api_event(api_event_obj: vertexai.types.SessionEvent) -> Event:
397431
branch = None
398432
custom_metadata = None
399433
compaction_data = None
434+
usage_metadata_data = None
400435
grounding_metadata = None
401436

402437
if actions:
@@ -416,6 +451,12 @@ def _from_api_event(api_event_obj: vertexai.types.SessionEvent) -> Event:
416451
else:
417452
event_actions = EventActions()
418453

454+
usage_metadata = None
455+
if usage_metadata_data:
456+
usage_metadata = types.GenerateContentResponseUsageMetadata.model_validate(
457+
usage_metadata_data
458+
)
459+
419460
return Event(
420461
id=api_event_obj.name.split('/')[-1],
421462
invocation_id=api_event_obj.invocation_id,
@@ -434,4 +475,5 @@ def _from_api_event(api_event_obj: vertexai.types.SessionEvent) -> Event:
434475
custom_metadata=custom_metadata,
435476
grounding_metadata=grounding_metadata,
436477
long_running_tool_ids=long_running_tool_ids,
478+
usage_metadata=usage_metadata,
437479
)

tests/unittests/sessions/test_vertex_ai_session_service.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,3 +911,131 @@ async def test_append_event_with_compaction_and_custom_metadata():
911911
# User custom_metadata is preserved without the internal _compaction key
912912
assert appended_event.custom_metadata == {'user_key': 'user_value'}
913913
assert '_compaction' not in (appended_event.custom_metadata or {})
914+
915+
916+
@pytest.mark.asyncio
917+
@pytest.mark.usefixtures('mock_get_api_client')
918+
async def test_append_event_with_usage_metadata():
919+
"""usage_metadata round-trips through append_event and get_session."""
920+
session_service = mock_vertex_ai_session_service()
921+
session = await session_service.get_session(
922+
app_name='123', user_id='user', session_id='1'
923+
)
924+
assert session is not None
925+
926+
event_to_append = Event(
927+
invocation_id='usage_invocation',
928+
author='model',
929+
timestamp=1734005536.0,
930+
usage_metadata=genai_types.GenerateContentResponseUsageMetadata(
931+
prompt_token_count=150,
932+
candidates_token_count=50,
933+
total_token_count=200,
934+
),
935+
)
936+
937+
await session_service.append_event(session, event_to_append)
938+
939+
retrieved_session = await session_service.get_session(
940+
app_name='123', user_id='user', session_id='1'
941+
)
942+
assert retrieved_session is not None
943+
944+
appended_event = retrieved_session.events[-1]
945+
assert appended_event.usage_metadata is not None
946+
assert appended_event.usage_metadata.prompt_token_count == 150
947+
assert appended_event.usage_metadata.candidates_token_count == 50
948+
assert appended_event.usage_metadata.total_token_count == 200
949+
# custom_metadata should remain None when only usage_metadata was stored
950+
assert appended_event.custom_metadata is None
951+
952+
953+
@pytest.mark.asyncio
954+
@pytest.mark.usefixtures('mock_get_api_client')
955+
async def test_append_event_with_usage_metadata_and_custom_metadata():
956+
"""Both usage_metadata and user custom_metadata survive the round-trip."""
957+
session_service = mock_vertex_ai_session_service()
958+
session = await session_service.get_session(
959+
app_name='123', user_id='user', session_id='1'
960+
)
961+
assert session is not None
962+
963+
event_to_append = Event(
964+
invocation_id='usage_and_meta_invocation',
965+
author='model',
966+
timestamp=1734005537.0,
967+
usage_metadata=genai_types.GenerateContentResponseUsageMetadata(
968+
prompt_token_count=300,
969+
total_token_count=400,
970+
),
971+
custom_metadata={'my_key': 'my_value'},
972+
)
973+
974+
await session_service.append_event(session, event_to_append)
975+
976+
retrieved_session = await session_service.get_session(
977+
app_name='123', user_id='user', session_id='1'
978+
)
979+
assert retrieved_session is not None
980+
981+
appended_event = retrieved_session.events[-1]
982+
# usage_metadata is restored
983+
assert appended_event.usage_metadata is not None
984+
assert appended_event.usage_metadata.prompt_token_count == 300
985+
assert appended_event.usage_metadata.total_token_count == 400
986+
# User custom_metadata is preserved without internal keys
987+
assert appended_event.custom_metadata == {'my_key': 'my_value'}
988+
assert '_usage_metadata' not in (appended_event.custom_metadata or {})
989+
990+
991+
@pytest.mark.asyncio
992+
@pytest.mark.usefixtures('mock_get_api_client')
993+
async def test_append_event_with_usage_metadata_and_compaction():
994+
"""usage_metadata, compaction, and user custom_metadata all coexist."""
995+
session_service = mock_vertex_ai_session_service()
996+
session = await session_service.get_session(
997+
app_name='123', user_id='user', session_id='1'
998+
)
999+
assert session is not None
1000+
1001+
compaction = EventCompaction(
1002+
start_timestamp=500.0,
1003+
end_timestamp=600.0,
1004+
compacted_content=genai_types.Content(
1005+
parts=[genai_types.Part(text='compacted')]
1006+
),
1007+
)
1008+
event_to_append = Event(
1009+
invocation_id='all_three_invocation',
1010+
author='model',
1011+
timestamp=1734005538.0,
1012+
actions=EventActions(compaction=compaction),
1013+
usage_metadata=genai_types.GenerateContentResponseUsageMetadata(
1014+
prompt_token_count=1000,
1015+
candidates_token_count=250,
1016+
total_token_count=1250,
1017+
),
1018+
custom_metadata={'extra': 'info'},
1019+
)
1020+
1021+
await session_service.append_event(session, event_to_append)
1022+
1023+
retrieved_session = await session_service.get_session(
1024+
app_name='123', user_id='user', session_id='1'
1025+
)
1026+
assert retrieved_session is not None
1027+
1028+
appended_event = retrieved_session.events[-1]
1029+
# Compaction is restored
1030+
assert appended_event.actions.compaction is not None
1031+
assert appended_event.actions.compaction.start_timestamp == 500.0
1032+
assert appended_event.actions.compaction.end_timestamp == 600.0
1033+
# usage_metadata is restored
1034+
assert appended_event.usage_metadata is not None
1035+
assert appended_event.usage_metadata.prompt_token_count == 1000
1036+
assert appended_event.usage_metadata.candidates_token_count == 250
1037+
assert appended_event.usage_metadata.total_token_count == 1250
1038+
# User custom_metadata is preserved without internal keys
1039+
assert appended_event.custom_metadata == {'extra': 'info'}
1040+
assert '_compaction' not in (appended_event.custom_metadata or {})
1041+
assert '_usage_metadata' not in (appended_event.custom_metadata or {})

0 commit comments

Comments
 (0)