Skip to content

Commit 480bc13

Browse files
fix(ai): carry Gemini URL context flag across stream chunks
URL context metadata can arrive on a different chunk than the final usage_metadata, so the tool-use prompt tokens were dropped in streaming. Track the flag across chunks in the sync and async clients. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 5f474f8 commit 480bc13

5 files changed

Lines changed: 100 additions & 6 deletions

File tree

‎posthog/ai/gemini/gemini.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
extract_gemini_embedding_token_count as extract_gemini_embedding_token_count,
2626
extract_gemini_stop_reason_from_chunk,
2727
extract_gemini_usage_from_chunk,
28+
has_gemini_url_context,
2829
format_gemini_streaming_output as format_gemini_streaming_output,
2930
)
3031

@@ -267,10 +268,16 @@ def generator():
267268
nonlocal accumulated_content
268269
nonlocal stop_reason
269270
stream_error: Optional[BaseException] = None
271+
url_context_seen = False
270272
try:
271273
for chunk in response:
272274
# Extract usage stats from chunk
273-
chunk_usage = extract_gemini_usage_from_chunk(chunk)
275+
url_context_seen = url_context_seen or has_gemini_url_context(
276+
chunk
277+
)
278+
chunk_usage = extract_gemini_usage_from_chunk(
279+
chunk, url_context_seen=url_context_seen
280+
)
274281

275282
if chunk_usage:
276283
# Gemini reports cumulative totals, not incremental values

‎posthog/ai/gemini/gemini_async.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
extract_gemini_embedding_token_count as extract_gemini_embedding_token_count,
2626
extract_gemini_stop_reason_from_chunk,
2727
extract_gemini_usage_from_chunk,
28+
has_gemini_url_context,
2829
format_gemini_streaming_output as format_gemini_streaming_output,
2930
)
3031

@@ -254,10 +255,16 @@ async def async_generator():
254255
nonlocal stop_reason
255256

256257
stream_error: Optional[BaseException] = None
258+
url_context_seen = False
257259
try:
258260
async for chunk in response:
259261
# Extract usage stats from chunk
260-
chunk_usage = extract_gemini_usage_from_chunk(chunk)
262+
url_context_seen = url_context_seen or has_gemini_url_context(
263+
chunk
264+
)
265+
chunk_usage = extract_gemini_usage_from_chunk(
266+
chunk, url_context_seen=url_context_seen
267+
)
261268

262269
if chunk_usage:
263270
# Gemini reports cumulative totals, not incremental values

‎posthog/ai/gemini/gemini_converter.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,7 @@ def extract_gemini_web_search_count(response: Any) -> int:
517517
return 0
518518

519519

520-
def _has_url_context(response: Any) -> bool:
520+
def has_gemini_url_context(response: Any) -> bool:
521521
"""Whether a response or chunk carries URL context retrieval metadata."""
522522
candidates = getattr(response, "candidates", None)
523523
if not isinstance(candidates, (list, tuple)):
@@ -597,7 +597,7 @@ def extract_gemini_usage_from_response(response: Any) -> TokenUsage:
597597
return TokenUsage()
598598

599599
usage = _extract_usage_from_metadata(
600-
response.usage_metadata, bill_tool_use_tokens=_has_url_context(response)
600+
response.usage_metadata, bill_tool_use_tokens=has_gemini_url_context(response)
601601
)
602602

603603
# Add web search count if present
@@ -608,12 +608,17 @@ def extract_gemini_usage_from_response(response: Any) -> TokenUsage:
608608
return usage
609609

610610

611-
def extract_gemini_usage_from_chunk(chunk: Any) -> TokenUsage:
611+
def extract_gemini_usage_from_chunk(
612+
chunk: Any, url_context_seen: bool = False
613+
) -> TokenUsage:
612614
"""
613615
Extract usage statistics from a Gemini streaming chunk.
614616
615617
Args:
616618
chunk: Streaming chunk from Gemini API
619+
url_context_seen: whether an earlier chunk of the same stream carried
620+
URL context metadata. Tool indicators can arrive on a different
621+
chunk than the final usage_metadata, so callers carry this flag.
617622
618623
Returns:
619624
TokenUsage with standardized usage statistics
@@ -631,7 +636,8 @@ def extract_gemini_usage_from_chunk(chunk: Any) -> TokenUsage:
631636
return usage
632637

633638
usage_from_metadata = _extract_usage_from_metadata(
634-
chunk.usage_metadata, bill_tool_use_tokens=_has_url_context(chunk)
639+
chunk.usage_metadata,
640+
bill_tool_use_tokens=url_context_seen or has_gemini_url_context(chunk),
635641
)
636642

637643
# Merge the usage from metadata with any web search count we found

‎posthog/test/ai/gemini/test_gemini.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1741,3 +1741,40 @@ def test_ai_lane_client_routes_through_capture_ai(
17411741
mock_client.capture.assert_not_called()
17421742
assert mock_client.capture_ai.call_count == 1
17431743
assert mock_client.capture_ai.call_args[1]["event"] == "$ai_generation"
1744+
1745+
1746+
def _url_context_stream_chunks():
1747+
from google.genai import types
1748+
1749+
# URL context metadata arrives on an early chunk; usage on the last one.
1750+
first = types.GenerateContentResponse(
1751+
candidates=[types.Candidate(url_context_metadata=types.UrlContextMetadata())]
1752+
)
1753+
last = types.GenerateContentResponse(
1754+
usage_metadata=types.GenerateContentResponseUsageMetadata(
1755+
prompt_token_count=20,
1756+
tool_use_prompt_token_count=300,
1757+
candidates_token_count=10,
1758+
total_token_count=330,
1759+
)
1760+
)
1761+
return first, last
1762+
1763+
1764+
def test_streaming_url_context_on_earlier_chunk_bills_tool_use_tokens(
1765+
mock_client, mock_google_genai_client
1766+
):
1767+
first, last = _url_context_stream_chunks()
1768+
mock_google_genai_client.models.generate_content_stream.return_value = iter(
1769+
[first, last]
1770+
)
1771+
1772+
client = Client(api_key="test-key", posthog_client=mock_client)
1773+
response = client.models.generate_content_stream(
1774+
model="gemini-2.0-flash", contents=["Summarise this page"]
1775+
)
1776+
list(response)
1777+
1778+
props = mock_client.capture.call_args[1]["properties"]
1779+
assert props["$ai_input_tokens"] == 320
1780+
assert props["$ai_output_tokens"] == 10

‎posthog/test/ai/gemini/test_gemini_async.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,3 +1178,40 @@ async def mock_streaming_response():
11781178
call_args = mock_client.capture.call_args[1]
11791179
assert call_args["event"] == "$ai_generation"
11801180
assert call_args["properties"]["$ai_provider"] == "gemini"
1181+
1182+
1183+
async def test_async_streaming_url_context_on_earlier_chunk_bills_tool_use_tokens(
1184+
mock_client, mock_google_genai_client
1185+
):
1186+
from google.genai import types
1187+
1188+
first = types.GenerateContentResponse(
1189+
candidates=[types.Candidate(url_context_metadata=types.UrlContextMetadata())]
1190+
)
1191+
last = types.GenerateContentResponse(
1192+
usage_metadata=types.GenerateContentResponseUsageMetadata(
1193+
prompt_token_count=20,
1194+
tool_use_prompt_token_count=300,
1195+
candidates_token_count=10,
1196+
total_token_count=330,
1197+
)
1198+
)
1199+
1200+
async def stream():
1201+
yield first
1202+
yield last
1203+
1204+
mock_google_genai_client.aio.models.generate_content_stream = AsyncMock(
1205+
return_value=stream()
1206+
)
1207+
1208+
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
1209+
response = await client.models.generate_content_stream(
1210+
model="gemini-2.0-flash", contents=["Summarise this page"]
1211+
)
1212+
async for _ in response:
1213+
pass
1214+
1215+
props = mock_client.capture.call_args[1]["properties"]
1216+
assert props["$ai_input_tokens"] == 320
1217+
assert props["$ai_output_tokens"] == 10

0 commit comments

Comments
 (0)