Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/gemini-cache-reporting-exclusive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: patch
---

fix: declare Gemini's cache accounting model on generations with cache reads, so ingestion prices cached tokens from `$ai_cache_reporting_exclusive` instead of inferring it from the token counts.
6 changes: 6 additions & 0 deletions posthog/ai/gemini/gemini_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,12 @@ def _extract_usage_from_metadata(metadata: Any) -> TokenUsage:
cache_tokens = metadata.cached_content_token_count
if cache_tokens and cache_tokens > 0:
usage["cache_read_input_tokens"] = cache_tokens
# Gemini counts cached_content_token_count inside prompt_token_count, so
# declare the accounting model rather than leaving ingestion to infer it.
# Under explicit context caching the two counts come from separate
# measurements and can disagree by a few percent, which makes inference
# from the token counts alone unreliable.
usage["cache_reporting_exclusive"] = False

# Add reasoning tokens if present (don't add if 0)
if hasattr(metadata, "thoughts_token_count"):
Expand Down
5 changes: 5 additions & 0 deletions posthog/ai/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ class TokenUsage(TypedDict, total=False):
cache_creation_input_tokens: Optional[int]
reasoning_tokens: Optional[int]
web_search_count: Optional[int]
# Whether cache tokens are counted separately from input_tokens. Providers that
# report them as a subset of input_tokens set this False. Left unset when the
# provider's accounting model is not known, in which case ingestion infers it
# from the token counts.
cache_reporting_exclusive: Optional[bool]
raw_usage: Optional[Any] # Raw provider usage metadata for backend processing


Expand Down
22 changes: 22 additions & 0 deletions posthog/ai/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ def merge_usage_stats(
current = target.get("web_search_count") or 0
target["web_search_count"] = max(current, source_web_search)

# Carried across, never accumulated: this describes the provider's accounting
# model, so it is the same on every chunk.
source_exclusive = source.get("cache_reporting_exclusive")
if source_exclusive is not None:
target["cache_reporting_exclusive"] = source_exclusive

# Merge raw_usage to avoid losing data from earlier events
# For Anthropic streaming: message_start has input tokens, message_delta has output
# Note: raw_usage is already serialized by converters, so it's a dict
Expand Down Expand Up @@ -199,6 +205,8 @@ def merge_usage_stats(
target["reasoning_tokens"] = source["reasoning_tokens"]
if source.get("web_search_count") is not None:
target["web_search_count"] = source["web_search_count"]
if source.get("cache_reporting_exclusive") is not None:
target["cache_reporting_exclusive"] = source["cache_reporting_exclusive"]
# Note: raw_usage is already serialized by converters, so it's a dict
if source.get("raw_usage") is not None:
target["raw_usage"] = source["raw_usage"]
Expand Down Expand Up @@ -482,6 +490,10 @@ def call_llm_and_track_usage(
if cache_creation is not None and cache_creation > 0:
tag("$ai_cache_creation_input_tokens", cache_creation)

cache_reporting_exclusive = usage.get("cache_reporting_exclusive")
if cache_reporting_exclusive is not None:
tag("$ai_cache_reporting_exclusive", cache_reporting_exclusive)

reasoning = usage.get("reasoning_tokens")
if reasoning is not None and reasoning > 0:
tag("$ai_reasoning_tokens", reasoning)
Expand Down Expand Up @@ -635,6 +647,10 @@ async def call_llm_and_track_usage_async(
if cache_creation is not None and cache_creation > 0:
tag("$ai_cache_creation_input_tokens", cache_creation)

cache_reporting_exclusive = usage.get("cache_reporting_exclusive")
if cache_reporting_exclusive is not None:
tag("$ai_cache_reporting_exclusive", cache_reporting_exclusive)

reasoning = usage.get("reasoning_tokens")
if reasoning is not None and reasoning > 0:
tag("$ai_reasoning_tokens", reasoning)
Expand Down Expand Up @@ -794,6 +810,12 @@ def capture_streaming_event(
if value is not None and isinstance(value, int) and value > 0:
event_properties[f"$ai_{field}"] = value

cache_reporting_exclusive = event_data["usage_stats"].get(
"cache_reporting_exclusive"
)
if cache_reporting_exclusive is not None:
event_properties["$ai_cache_reporting_exclusive"] = cache_reporting_exclusive

# Add web search count if present (all providers)
web_search_count = event_data["usage_stats"].get("web_search_count")
if (
Expand Down
2 changes: 2 additions & 0 deletions posthog/test/ai/gemini/test_gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,7 @@ def test_cache_and_reasoning_tokens(mock_client, mock_google_genai_client):
assert props["$ai_output_tokens"] == 50
assert props["$ai_cache_read_input_tokens"] == 30
assert props["$ai_reasoning_tokens"] == 10
assert props["$ai_cache_reporting_exclusive"] is False


def test_streaming_cache_and_reasoning_tokens(mock_client, mock_google_genai_client):
Expand Down Expand Up @@ -899,6 +900,7 @@ def test_streaming_cache_and_reasoning_tokens(mock_client, mock_google_genai_cli
assert props["$ai_output_tokens"] == 10
assert props["$ai_cache_read_input_tokens"] == 30
assert props["$ai_reasoning_tokens"] == 5
assert props["$ai_cache_reporting_exclusive"] is False

# Verify raw usage is captured in streaming mode (merged from chunks)
assert "$ai_usage" in props
Expand Down
1 change: 1 addition & 0 deletions references/public_api_snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ attribute posthog.ai.types.StreamingEventData.trace_id: Optional[str]
attribute posthog.ai.types.StreamingEventData.usage_stats: TokenUsage
attribute posthog.ai.types.TokenUsage.cache_creation_input_tokens: Optional[int]
attribute posthog.ai.types.TokenUsage.cache_read_input_tokens: Optional[int]
attribute posthog.ai.types.TokenUsage.cache_reporting_exclusive: Optional[bool]
attribute posthog.ai.types.TokenUsage.input_tokens: int
attribute posthog.ai.types.TokenUsage.output_tokens: int
attribute posthog.ai.types.TokenUsage.raw_usage: Optional[Any]
Expand Down