From 9dc9d56b8f3bfb344da25bacf3cedfe23c9920cb Mon Sep 17 00:00:00 2001 From: coryslater <25396141+fivestarspicy@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:44:44 -0700 Subject: [PATCH 1/2] fix(gemini): declare cache reporting as inclusive on generations Gemini counts `cached_content_token_count` inside `prompt_token_count`, but the SDK never said so, leaving ingestion to infer the accounting model from the token counts alone. That inference is unreliable here. Under explicit context caching the two counts come from separate measurements, the cache object at creation time and the prompt per request, so they can disagree by a few percent and the cache pool can land just above the input total. Set `cache_reporting_exclusive` to False on generations that report cache reads, and carry it through the streaming merge and both capture paths so ingestion prices cached tokens from the declared value. Generated-By: PostHog Code Task-Id: 06160e48-feb9-4d39-8b7b-3dcfd1d9ca24 --- .../gemini-cache-reporting-exclusive.md | 5 +++++ posthog/ai/gemini/gemini_converter.py | 6 +++++ posthog/ai/types.py | 5 +++++ posthog/ai/utils.py | 22 +++++++++++++++++++ posthog/test/ai/gemini/test_gemini.py | 2 ++ 5 files changed, 40 insertions(+) create mode 100644 .sampo/changesets/gemini-cache-reporting-exclusive.md diff --git a/.sampo/changesets/gemini-cache-reporting-exclusive.md b/.sampo/changesets/gemini-cache-reporting-exclusive.md new file mode 100644 index 00000000..38800b78 --- /dev/null +++ b/.sampo/changesets/gemini-cache-reporting-exclusive.md @@ -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. diff --git a/posthog/ai/gemini/gemini_converter.py b/posthog/ai/gemini/gemini_converter.py index b746290e..b5548be4 100644 --- a/posthog/ai/gemini/gemini_converter.py +++ b/posthog/ai/gemini/gemini_converter.py @@ -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"): diff --git a/posthog/ai/types.py b/posthog/ai/types.py index 6670f12a..4c9a3e87 100644 --- a/posthog/ai/types.py +++ b/posthog/ai/types.py @@ -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 diff --git a/posthog/ai/utils.py b/posthog/ai/utils.py index ec369031..057fec63 100644 --- a/posthog/ai/utils.py +++ b/posthog/ai/utils.py @@ -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 @@ -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"] @@ -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) @@ -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) @@ -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 ( diff --git a/posthog/test/ai/gemini/test_gemini.py b/posthog/test/ai/gemini/test_gemini.py index c1b82fe0..8bb3ddff 100644 --- a/posthog/test/ai/gemini/test_gemini.py +++ b/posthog/test/ai/gemini/test_gemini.py @@ -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): @@ -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 From c6d1f3498fbdf06e43ff25e079c3d38c44be4b03 Mon Sep 17 00:00:00 2001 From: coryslater <25396141+fivestarspicy@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:52:10 -0700 Subject: [PATCH 2/2] chore(ai): regenerate public API snapshot for TokenUsage field Generated-By: PostHog Code Task-Id: 06160e48-feb9-4d39-8b7b-3dcfd1d9ca24 --- references/public_api_snapshot.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index cd0a1dda..34307b03 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -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]