Python: surface cache and reasoning token counts for the Bedrock and Gemini connectors (#6640)

* Python: surface Gemini cached and thinking token counts in usage details

* Python: surface Bedrock cache token counts in usage details

* Python: surface Gemini cached and thinking token counts in usage details

* Python: surface Bedrock cache token counts in usage details

* Return None from Bedrock _parse_usage when no token counts are present

Matches the UsageDetails | None return annotation and the Gemini
connector's behavior, so a usage payload with no recognized keys no
longer propagates an empty mapping. Adds a regression test.
This commit is contained in:
Yufeng He
2026-06-25 01:09:01 +08:00
committed by GitHub
parent 91f639a694
commit 1df47667ea
4 changed files with 62 additions and 1 deletions
@@ -689,7 +689,12 @@ class BedrockChatClient(
details["output_token_count"] = output_tokens
if (total_tokens := usage.get("totalTokens")) is not None:
details["total_token_count"] = total_tokens
return details
# Bedrock Converse reports these when prompt caching is active.
if (cache_read := usage.get("cacheReadInputTokens")) is not None:
details["cache_read_input_token_count"] = cache_read
if (cache_write := usage.get("cacheWriteInputTokens")) is not None:
details["cache_creation_input_token_count"] = cache_write
return details or None
def _parse_message_contents(self, content_blocks: Sequence[dict[str, Any]]) -> list[Any]:
contents: list[Any] = []
@@ -169,3 +169,30 @@ def test_prepare_options_tool_choice_required_without_tools_raises() -> None:
with pytest.raises(ValueError, match="tool_choice='required' requires at least one tool"):
client._prepare_options(messages, options)
def test_parse_usage_surfaces_cache_tokens() -> None:
"""Bedrock Converse reports cache token counts when prompt caching is used."""
client = _make_client()
details = client._parse_usage({
"inputTokens": 10,
"outputTokens": 5,
"totalTokens": 15,
"cacheReadInputTokens": 8,
"cacheWriteInputTokens": 3,
})
assert details is not None
assert details["input_token_count"] == 10
assert details["cache_read_input_token_count"] == 8
assert details["cache_creation_input_token_count"] == 3
def test_parse_usage_returns_none_when_no_recognized_keys() -> None:
"""A truthy usage payload with no recognized keys yields None, not an empty mapping."""
client = _make_client()
assert client._parse_usage({"unexpected": 1}) is None
assert client._parse_usage({}) is None
assert client._parse_usage(None) is None
@@ -1051,6 +1051,10 @@ class RawGeminiChatClient(
details["output_token_count"] = v
if (v := usage.total_token_count) is not None:
details["total_token_count"] = v
if (v := usage.cached_content_token_count) is not None:
details["cache_read_input_token_count"] = v
if (v := usage.thoughts_token_count) is not None:
details["reasoning_output_token_count"] = v
return details or None
def _map_finish_reason(self, reason: str | None) -> FinishReasonLiteral | None:
@@ -93,6 +93,8 @@ def _make_response(
prompt_tokens: int | None = 10,
output_tokens: int | None = 5,
total_tokens: int | None = 15,
cached_tokens: int | None = None,
thoughts_tokens: int | None = None,
) -> MagicMock:
"""Build a mock types.GenerateContentResponse."""
response = MagicMock()
@@ -113,6 +115,8 @@ def _make_response(
usage.prompt_token_count = prompt_tokens
usage.candidates_token_count = output_tokens
usage.total_token_count = total_tokens
usage.cached_content_token_count = cached_tokens
usage.thoughts_token_count = thoughts_tokens
response.usage_metadata = usage
else:
response.usage_metadata = None
@@ -374,6 +378,27 @@ async def test_get_response_usage_details() -> None:
assert response.usage_details["total_token_count"] == 28
async def test_get_response_usage_details_includes_cached_and_reasoning_tokens() -> None:
"""Surfaces Gemini cached-content and thinking token counts into the canonical usage fields."""
client, mock = _make_gemini_client()
mock.aio.models.generate_content = AsyncMock(
return_value=_make_response(
[_make_part(text="Hi")],
prompt_tokens=20,
output_tokens=8,
total_tokens=28,
cached_tokens=12,
thoughts_tokens=6,
)
)
response = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])])
assert response.usage_details is not None
assert response.usage_details["cache_read_input_token_count"] == 12
assert response.usage_details["reasoning_output_token_count"] == 6
async def test_get_response_no_usage_when_metadata_absent() -> None:
"""Returns None for usage_details when the API response includes no usage metadata."""
client, mock = _make_gemini_client()