Skip to content

Commit 5072828

Browse files
GWealecopybara-github
authored andcommitted
feat: record implicit vs explicit context cache type in analytics
Cached token counts alone cannot distinguish Gemini provider-side implicit prefix caching from ADK-managed explicit CachedContent. Derive a cache_type (explicit/implicit/none) on the final response and expose it in the BigQuery analytics view so the two can be reported separately. Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 962267586
1 parent d63a255 commit 5072828

2 files changed

Lines changed: 229 additions & 2 deletions

File tree

src/google/adk/plugins/bigquery_agent_analytics_plugin.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3655,6 +3655,9 @@ def _parse_custom_metadata_allowlist(
36553655
"JSON_VALUE(attributes, '$.model_version') AS model_version",
36563656
"JSON_QUERY(attributes, '$.usage_metadata') AS usage_metadata",
36573657
"JSON_QUERY(attributes, '$.cache_metadata') AS cache_metadata",
3658+
# NULL on partial streaming rows and pre-CL rows; filter to final
3659+
# responses before aggregating on cache_type.
3660+
"JSON_VALUE(attributes, '$.cache_type') AS cache_type",
36583661
],
36593662
"LLM_ERROR": [
36603663
"CAST(JSON_VALUE(latency_ms, '$.total_ms') AS INT64) AS total_ms",
@@ -6761,6 +6764,9 @@ async def after_model_callback(
67616764
is_popped = False
67626765
duration = 0
67636766
tfft = None
6767+
extra_attributes: dict[str, Any] = {}
6768+
usage_metadata = llm_response.usage_metadata
6769+
cache_metadata = getattr(llm_response, "cache_metadata", None)
67646770

67656771
if hasattr(llm_response, "partial") and llm_response.partial:
67666772
# Streaming chunk - do NOT pop span yet
@@ -6796,6 +6802,29 @@ async def after_model_callback(
67966802
# Otherwise log_event will fetch current stack (which is parent).
67976803
span_id = popped_span_id or span_id
67986804

6805+
# cache_type classifies the cached-token hit so analytics can separate
6806+
# ADK-managed explicit caching from Gemini provider-side implicit prefix
6807+
# caching (the two are indistinguishable from token counts alone).
6808+
# cache_metadata is attached only when ADK explicit caching is configured,
6809+
# so its presence means explicit (including the fingerprint-only,
6810+
# cache_name=None state). No cached tokens -> "none", regardless of
6811+
# whether a cache is configured. Only derived on the final response.
6812+
# Token counts carry no source, so when ADK caching is configured the
6813+
# cache_metadata presence wins the "explicit" label even if some of the
6814+
# cached tokens came from provider-side implicit caching.
6815+
cached = bool(
6816+
usage_metadata
6817+
and (getattr(usage_metadata, "cached_content_token_count", 0) or 0)
6818+
> 0
6819+
)
6820+
if not cached:
6821+
cache_type = "none"
6822+
elif cache_metadata is not None:
6823+
cache_type = "explicit"
6824+
else:
6825+
cache_type = "implicit"
6826+
extra_attributes["cache_type"] = cache_type
6827+
67996828
await self._log_event(
68006829
"LLM_RESPONSE",
68016830
callback_context,
@@ -6805,10 +6834,11 @@ async def after_model_callback(
68056834
latency_ms=duration,
68066835
time_to_first_token_ms=tfft,
68076836
model_version=llm_response.model_version,
6808-
usage_metadata=llm_response.usage_metadata,
6809-
cache_metadata=getattr(llm_response, "cache_metadata", None),
6837+
usage_metadata=usage_metadata,
6838+
cache_metadata=cache_metadata,
68106839
span_id_override=span_id if is_popped else None,
68116840
parent_span_id_override=(parent_span_id if is_popped else None),
6841+
extra_attributes=extra_attributes,
68126842
),
68136843
)
68146844

tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8088,6 +8088,203 @@ def __init__(self):
80888088
attributes = json.loads(log_entry["attributes"])
80898089
assert "cache_metadata" not in attributes
80908090

8091+
async def _run_after_model(
8092+
self,
8093+
bq_plugin_inst,
8094+
mock_write_client,
8095+
callback_context,
8096+
dummy_arrow_schema,
8097+
llm_response,
8098+
):
8099+
"""Drives after_model_callback and returns the LLM_RESPONSE attributes."""
8100+
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
8101+
await bq_plugin_inst.after_model_callback(
8102+
callback_context=callback_context,
8103+
llm_response=llm_response,
8104+
)
8105+
await asyncio.sleep(0.05)
8106+
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
8107+
log_entry = next(r for r in rows if r["event_type"] == "LLM_RESPONSE")
8108+
return json.loads(log_entry["attributes"])
8109+
8110+
@pytest.mark.asyncio
8111+
async def test_cache_type_explicit(
8112+
self,
8113+
bq_plugin_inst,
8114+
mock_write_client,
8115+
callback_context,
8116+
dummy_arrow_schema,
8117+
):
8118+
"""cache_name set + cached tokens -> explicit (ADK-managed cache)."""
8119+
llm_response = llm_response_lib.LlmResponse(
8120+
content=types.Content(parts=[types.Part(text="hi")]),
8121+
usage_metadata=types.GenerateContentResponseUsageMetadata(
8122+
prompt_token_count=100,
8123+
candidates_token_count=20,
8124+
total_token_count=120,
8125+
cached_content_token_count=80,
8126+
),
8127+
cache_metadata={
8128+
"cache_name": "projects/p/locations/us-central1/cachedContents/c",
8129+
"expire_time": 9999999999.0,
8130+
"fingerprint": "fp-1",
8131+
"invocations_used": 1,
8132+
"contents_count": 2,
8133+
"created_at": 1.0,
8134+
},
8135+
)
8136+
attributes = await self._run_after_model(
8137+
bq_plugin_inst,
8138+
mock_write_client,
8139+
callback_context,
8140+
dummy_arrow_schema,
8141+
llm_response,
8142+
)
8143+
assert attributes["cache_type"] == "explicit"
8144+
8145+
@pytest.mark.asyncio
8146+
async def test_cache_type_implicit(
8147+
self,
8148+
bq_plugin_inst,
8149+
mock_write_client,
8150+
callback_context,
8151+
dummy_arrow_schema,
8152+
):
8153+
"""Cached tokens with no cache_metadata -> implicit (provider prefix)."""
8154+
llm_response = llm_response_lib.LlmResponse(
8155+
content=types.Content(parts=[types.Part(text="hi")]),
8156+
usage_metadata=types.GenerateContentResponseUsageMetadata(
8157+
prompt_token_count=100,
8158+
candidates_token_count=20,
8159+
total_token_count=120,
8160+
cached_content_token_count=80,
8161+
),
8162+
)
8163+
attributes = await self._run_after_model(
8164+
bq_plugin_inst,
8165+
mock_write_client,
8166+
callback_context,
8167+
dummy_arrow_schema,
8168+
llm_response,
8169+
)
8170+
assert attributes["cache_type"] == "implicit"
8171+
8172+
@pytest.mark.asyncio
8173+
async def test_cache_type_explicit_fingerprint_only(
8174+
self,
8175+
bq_plugin_inst,
8176+
mock_write_client,
8177+
callback_context,
8178+
dummy_arrow_schema,
8179+
):
8180+
"""Fingerprint-only cache_metadata (cache_name=None) is still explicit."""
8181+
llm_response = llm_response_lib.LlmResponse(
8182+
content=types.Content(parts=[types.Part(text="hi")]),
8183+
usage_metadata=types.GenerateContentResponseUsageMetadata(
8184+
prompt_token_count=100,
8185+
candidates_token_count=20,
8186+
total_token_count=120,
8187+
cached_content_token_count=80,
8188+
),
8189+
cache_metadata={"fingerprint": "fp-1", "contents_count": 2},
8190+
)
8191+
attributes = await self._run_after_model(
8192+
bq_plugin_inst,
8193+
mock_write_client,
8194+
callback_context,
8195+
dummy_arrow_schema,
8196+
llm_response,
8197+
)
8198+
assert attributes["cache_type"] == "explicit"
8199+
8200+
@pytest.mark.asyncio
8201+
async def test_cache_type_none_with_active_cache(
8202+
self,
8203+
bq_plugin_inst,
8204+
mock_write_client,
8205+
callback_context,
8206+
dummy_arrow_schema,
8207+
):
8208+
"""Active cache but no cached tokens (creation turn / miss) -> none."""
8209+
llm_response = llm_response_lib.LlmResponse(
8210+
content=types.Content(parts=[types.Part(text="hi")]),
8211+
usage_metadata=types.GenerateContentResponseUsageMetadata(
8212+
prompt_token_count=100,
8213+
candidates_token_count=20,
8214+
total_token_count=120,
8215+
),
8216+
cache_metadata={
8217+
"cache_name": "projects/p/locations/us-central1/cachedContents/c",
8218+
"expire_time": 9999999999.0,
8219+
"fingerprint": "fp-1",
8220+
"invocations_used": 1,
8221+
"contents_count": 2,
8222+
"created_at": 1.0,
8223+
},
8224+
)
8225+
attributes = await self._run_after_model(
8226+
bq_plugin_inst,
8227+
mock_write_client,
8228+
callback_context,
8229+
dummy_arrow_schema,
8230+
llm_response,
8231+
)
8232+
assert attributes["cache_type"] == "none"
8233+
8234+
@pytest.mark.asyncio
8235+
async def test_cache_type_none(
8236+
self,
8237+
bq_plugin_inst,
8238+
mock_write_client,
8239+
callback_context,
8240+
dummy_arrow_schema,
8241+
):
8242+
"""No cached tokens -> none."""
8243+
llm_response = llm_response_lib.LlmResponse(
8244+
content=types.Content(parts=[types.Part(text="hi")]),
8245+
usage_metadata=types.GenerateContentResponseUsageMetadata(
8246+
prompt_token_count=100,
8247+
candidates_token_count=20,
8248+
total_token_count=120,
8249+
),
8250+
)
8251+
attributes = await self._run_after_model(
8252+
bq_plugin_inst,
8253+
mock_write_client,
8254+
callback_context,
8255+
dummy_arrow_schema,
8256+
llm_response,
8257+
)
8258+
assert attributes["cache_type"] == "none"
8259+
8260+
@pytest.mark.asyncio
8261+
async def test_cache_type_absent_on_partial_response(
8262+
self,
8263+
bq_plugin_inst,
8264+
mock_write_client,
8265+
callback_context,
8266+
dummy_arrow_schema,
8267+
):
8268+
"""Partial streaming rows carry no cache_type, even with cached tokens."""
8269+
llm_response = llm_response_lib.LlmResponse(
8270+
content=types.Content(parts=[types.Part(text="hi")]),
8271+
partial=True,
8272+
usage_metadata=types.GenerateContentResponseUsageMetadata(
8273+
prompt_token_count=100,
8274+
candidates_token_count=20,
8275+
total_token_count=120,
8276+
cached_content_token_count=80,
8277+
),
8278+
)
8279+
attributes = await self._run_after_model(
8280+
bq_plugin_inst,
8281+
mock_write_client,
8282+
callback_context,
8283+
dummy_arrow_schema,
8284+
llm_response,
8285+
)
8286+
assert "cache_type" not in attributes
8287+
80918288

80928289
# ==============================================================
80938290
# TEST CLASS: A2A_INTERACTION event logging via on_event_callback

0 commit comments

Comments
 (0)