Skip to content

Commit b15c8a0

Browse files
GWealecopybara-github
authored andcommitted
feat: report cached token counts for Anthropic and OpenAI models
Populate usage_metadata.cached_content_token_count from provider usage so cache reads stop being reported as misses (matches LiteLlm). Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 933261404
1 parent 8c92cde commit b15c8a0

4 files changed

Lines changed: 195 additions & 0 deletions

File tree

src/google/adk/labs/openai/_openai_llm.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,13 @@ def _function_declaration_to_openai_tool(
298298
}
299299

300300

301+
def _extract_cached_token_count(usage: Any) -> int | None:
302+
"""Returns OpenAI prompt_tokens_details.cached_tokens, if present."""
303+
details = getattr(usage, "prompt_tokens_details", None)
304+
cached = getattr(details, "cached_tokens", None)
305+
return cached if isinstance(cached, int) else None
306+
307+
301308
def _response_to_llm_response(response: ChatCompletion) -> LlmResponse:
302309
"""Parses an OpenAI response into an LlmResponse."""
303310
choice = response.choices[0]
@@ -331,6 +338,9 @@ def _response_to_llm_response(response: ChatCompletion) -> LlmResponse:
331338
prompt_token_count=response.usage.prompt_tokens,
332339
candidates_token_count=response.usage.completion_tokens,
333340
total_token_count=response.usage.total_tokens,
341+
cached_content_token_count=_extract_cached_token_count(
342+
response.usage
343+
),
334344
),
335345
)
336346

src/google/adk/models/anthropic_llm.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,12 @@ def content_block_to_part(
380380
)
381381

382382

383+
def _extract_cached_token_count(usage: Any) -> int | None:
384+
"""Returns Anthropic cache-read tokens, the analog of cached_content tokens."""
385+
cached = getattr(usage, "cache_read_input_tokens", None)
386+
return cached if isinstance(cached, int) else None
387+
388+
383389
def message_to_generate_content_response(
384390
message: anthropic_types.Message,
385391
) -> LlmResponse:
@@ -402,6 +408,7 @@ def message_to_generate_content_response(
402408
total_token_count=(
403409
message.usage.input_tokens + message.usage.output_tokens
404410
),
411+
cached_content_token_count=_extract_cached_token_count(message.usage),
405412
),
406413
# TODO: Deal with these later.
407414
# finish_reason=to_google_genai_finish_reason(message.stop_reason),
@@ -612,11 +619,13 @@ async def _generate_content_streaming(
612619
redacted_thinking_blocks: dict[int, str] = {}
613620
input_tokens = 0
614621
output_tokens = 0
622+
cached_input_tokens: int | None = None
615623

616624
async for event in raw_stream:
617625
if event.type == "message_start":
618626
input_tokens = event.message.usage.input_tokens
619627
output_tokens = event.message.usage.output_tokens
628+
cached_input_tokens = _extract_cached_token_count(event.message.usage)
620629

621630
elif event.type == "content_block_start":
622631
block = event.content_block
@@ -708,6 +717,7 @@ async def _generate_content_streaming(
708717
prompt_token_count=input_tokens,
709718
candidates_token_count=output_tokens,
710719
total_token_count=input_tokens + output_tokens,
720+
cached_content_token_count=cached_input_tokens,
711721
),
712722
partial=False,
713723
)

tests/unittests/labs/openai/test_openai_llm.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,3 +349,120 @@ async def mock_create(*args, **kwargs):
349349
assert content[0]["text"] == "Analyze this"
350350
assert content[1]["type"] == "image_url"
351351
assert content[1]["image_url"]["url"].startswith("data:image/png;base64,")
352+
353+
354+
def _completion_with_cached_tokens(cached_tokens):
355+
"""Builds a mock ChatCompletion whose usage carries prompt_tokens_details."""
356+
mock_response = mock.MagicMock()
357+
mock_choice = mock.MagicMock()
358+
mock_message = mock.MagicMock()
359+
mock_message.content = "Hello there!"
360+
mock_message.tool_calls = None
361+
mock_choice.message = mock_message
362+
mock_response.choices = [mock_choice]
363+
mock_response.usage.prompt_tokens = 100
364+
mock_response.usage.completion_tokens = 5
365+
mock_response.usage.total_tokens = 105
366+
if cached_tokens is None:
367+
mock_response.usage.prompt_tokens_details = None
368+
else:
369+
mock_response.usage.prompt_tokens_details.cached_tokens = cached_tokens
370+
return mock_response
371+
372+
373+
@pytest.mark.asyncio
374+
async def test_generate_content_async_reports_cached_tokens():
375+
"""prompt_tokens_details.cached_tokens populates cached_content_token_count."""
376+
with mock.patch.dict(os.environ, {"OPENAI_API_KEY": "test_key"}):
377+
openai_llm = OpenAILlm(model="gpt-4o")
378+
llm_request = LlmRequest(
379+
model="gpt-4o",
380+
contents=[Content(role="user", parts=[Part.from_text(text="Hello")])],
381+
)
382+
383+
mock_response = _completion_with_cached_tokens(64)
384+
385+
async def mock_create(*args, **kwargs):
386+
return mock_response
387+
388+
with mock.patch(
389+
"google.adk.labs.openai._openai_llm.AsyncOpenAI"
390+
) as mock_client_class:
391+
mock_client = mock.MagicMock()
392+
mock_client_class.return_value = mock_client
393+
mock_client.chat.completions.create = mock_create
394+
395+
responses = [
396+
resp
397+
async for resp in openai_llm.generate_content_async(
398+
llm_request, stream=False
399+
)
400+
]
401+
402+
assert len(responses) == 1
403+
assert responses[0].usage_metadata.cached_content_token_count == 64
404+
assert responses[0].usage_metadata.prompt_token_count == 100
405+
406+
407+
@pytest.mark.asyncio
408+
async def test_generate_content_async_zero_cached_tokens():
409+
"""No cache hit (cached_tokens=0) reports 0, not a regression."""
410+
with mock.patch.dict(os.environ, {"OPENAI_API_KEY": "test_key"}):
411+
openai_llm = OpenAILlm(model="gpt-4o")
412+
llm_request = LlmRequest(
413+
model="gpt-4o",
414+
contents=[Content(role="user", parts=[Part.from_text(text="Hello")])],
415+
)
416+
417+
mock_response = _completion_with_cached_tokens(0)
418+
419+
async def mock_create(*args, **kwargs):
420+
return mock_response
421+
422+
with mock.patch(
423+
"google.adk.labs.openai._openai_llm.AsyncOpenAI"
424+
) as mock_client_class:
425+
mock_client = mock.MagicMock()
426+
mock_client_class.return_value = mock_client
427+
mock_client.chat.completions.create = mock_create
428+
429+
responses = [
430+
resp
431+
async for resp in openai_llm.generate_content_async(
432+
llm_request, stream=False
433+
)
434+
]
435+
436+
assert responses[0].usage_metadata.cached_content_token_count == 0
437+
438+
439+
@pytest.mark.asyncio
440+
async def test_generate_content_async_absent_prompt_tokens_details():
441+
"""Missing prompt_tokens_details maps to None (no cached count reported)."""
442+
with mock.patch.dict(os.environ, {"OPENAI_API_KEY": "test_key"}):
443+
openai_llm = OpenAILlm(model="gpt-4o")
444+
llm_request = LlmRequest(
445+
model="gpt-4o",
446+
contents=[Content(role="user", parts=[Part.from_text(text="Hello")])],
447+
)
448+
449+
mock_response = _completion_with_cached_tokens(None)
450+
451+
async def mock_create(*args, **kwargs):
452+
return mock_response
453+
454+
with mock.patch(
455+
"google.adk.labs.openai._openai_llm.AsyncOpenAI"
456+
) as mock_client_class:
457+
mock_client = mock.MagicMock()
458+
mock_client_class.return_value = mock_client
459+
mock_client.chat.completions.create = mock_create
460+
461+
responses = [
462+
resp
463+
async for resp in openai_llm.generate_content_async(
464+
llm_request, stream=False
465+
)
466+
]
467+
468+
assert responses[0].usage_metadata.cached_content_token_count is None

tests/unittests/models/test_anthropic_llm.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1633,6 +1633,64 @@ def test_message_to_generate_content_response_with_thinking():
16331633
assert text_part.thought is not True
16341634

16351635

1636+
def test_message_to_generate_content_response_reports_cache_read_tokens():
1637+
"""cache_read_input_tokens maps to usage_metadata.cached_content_token_count."""
1638+
from google.adk.models.anthropic_llm import message_to_generate_content_response
1639+
1640+
message = anthropic_types.Message(
1641+
id="msg_cache_read",
1642+
content=[
1643+
anthropic_types.TextBlock(text="hi", type="text", citations=None)
1644+
],
1645+
model="claude-sonnet-4-20250514",
1646+
role="assistant",
1647+
stop_reason="end_turn",
1648+
stop_sequence=None,
1649+
type="message",
1650+
usage=anthropic_types.Usage(
1651+
input_tokens=100,
1652+
output_tokens=20,
1653+
cache_creation_input_tokens=0,
1654+
cache_read_input_tokens=75,
1655+
server_tool_use=None,
1656+
service_tier=None,
1657+
),
1658+
)
1659+
1660+
response = message_to_generate_content_response(message)
1661+
1662+
assert response.usage_metadata.cached_content_token_count == 75
1663+
1664+
1665+
def test_message_to_generate_content_response_no_cache_read_tokens():
1666+
"""Absent cache_read_input_tokens yields cached_content_token_count=None."""
1667+
from google.adk.models.anthropic_llm import message_to_generate_content_response
1668+
1669+
message = anthropic_types.Message(
1670+
id="msg_no_cache",
1671+
content=[
1672+
anthropic_types.TextBlock(text="hi", type="text", citations=None)
1673+
],
1674+
model="claude-sonnet-4-20250514",
1675+
role="assistant",
1676+
stop_reason="end_turn",
1677+
stop_sequence=None,
1678+
type="message",
1679+
usage=anthropic_types.Usage(
1680+
input_tokens=100,
1681+
output_tokens=20,
1682+
cache_creation_input_tokens=0,
1683+
cache_read_input_tokens=None,
1684+
server_tool_use=None,
1685+
service_tier=None,
1686+
),
1687+
)
1688+
1689+
response = message_to_generate_content_response(message)
1690+
1691+
assert response.usage_metadata.cached_content_token_count is None
1692+
1693+
16361694
def test_part_to_message_block_thinking_roundtrip():
16371695
"""Part with thought=True and signature creates ThinkingBlockParam."""
16381696
part = Part(

0 commit comments

Comments
 (0)