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 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]