fix(gemini): declare cache reporting as inclusive on generations - #860
Merged
Conversation
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
1 task
Contributor
posthog-python Compliance ReportDate: 2026-08-07 17:57:06 UTC ✅ All Tests Passed!111/111 tests passed Capture_V1 Tests✅ 94/94 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
Generated-By: PostHog Code Task-Id: 06160e48-feb9-4d39-8b7b-3dcfd1d9ca24
Radu-Raicea
marked this pull request as ready for review
August 7, 2026 17:58
fivestarspicy
added a commit
to PostHog/posthog-js
that referenced
this pull request
Aug 7, 2026
Gemini counts `cachedContentTokenCount` inside `promptTokenCount`, 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 `cacheReportingExclusive` to false on generations that report cache reads, on both the streaming and non-streaming paths, and map it onto `$ai_cache_reporting_exclusive`. The property mapping checks against undefined rather than truthiness, because false is the meaningful value here. Mirrors PostHog/posthog-python#860. Generated-By: PostHog Code Task-Id: 06160e48-feb9-4d39-8b7b-3dcfd1d9ca24
Radu-Raicea
approved these changes
Aug 7, 2026
This was referenced Aug 7, 2026
Radu-Raicea
pushed a commit
to PostHog/posthog-js
that referenced
this pull request
Aug 7, 2026
…4473) * fix(ai): declare gemini cache reporting as inclusive on generations Gemini counts `cachedContentTokenCount` inside `promptTokenCount`, 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 `cacheReportingExclusive` to false on generations that report cache reads, on both the streaming and non-streaming paths, and map it onto `$ai_cache_reporting_exclusive`. The property mapping checks against undefined rather than truthiness, because false is the meaningful value here. Mirrors PostHog/posthog-python#860. Generated-By: PostHog Code Task-Id: 06160e48-feb9-4d39-8b7b-3dcfd1d9ca24 * fix(ai): drop derived cache reporting flag when token counts are overridden The derived flag describes how the SDK's own input and cache counts relate to each other. When a caller passes their own token counts through `posthogProperties`, those override the derived counts but the flag survived alongside them, so it could describe numbers that are no longer on the event. That is wrong in the expensive direction: declaring inclusive over counts that are actually exclusive makes ingestion subtract a cache pool that was never part of the input. Suppress the derived flag whenever the caller overrides any token count. A caller who knows their own accounting model can still pass `$ai_cache_reporting_exclusive` explicitly, and that value wins. Extracts the passthrough check behind `hasTokenOverrides`, which `getTokensSource` now shares. Generated-By: PostHog Code Task-Id: 06160e48-feb9-4d39-8b7b-3dcfd1d9ca24
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
💡 Motivation and Context
Cache-heavy Gemini generations can be priced wrong in AI observability, because the SDK never tells ingestion how Gemini counts cache tokens.
Gemini counts
cached_content_token_countinsideprompt_token_count, so cache reads are a subset of input, not a separate pool. The SDK reports both numbers but declares nothing, so ingestion infers the accounting model from the token counts.That inference is unreliable for Gemini. Under explicit context caching the two counts come from separate measurements: the cache object is counted when it is created, and the prompt is counted per request. They describe the same tokens but can disagree by a few percent, which puts the cache pool just above the input total and makes the counts look like two separate pools.
This PR sets the flag where the answer is actually known. PostHog/posthog#79781 is the matching ingestion-side change, which stops inferring exclusive accounting from a small overshoot and covers SDK versions that predate this one.
💚 How did you test it?
uv run pytest posthog/test/ai/ --ignore=posthog/test/ai/otel. 446 passed. Two failures,test_integration_stop_reasonin the Anthropic and OpenAI suites, need live provider credentials and fail the same way on a clean checkout.test_cache_and_reasoning_tokensandtest_streaming_cache_and_reasoning_tokensto assert$ai_cache_reporting_exclusive is False. These cover the two capture paths separately, so they catch the flag being dropped in the converter, in the streaming merge, or at either tagging site.📝 Checklist
If releasing new changes
sampo addto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
I (the PostHog Slack app, running Claude) traced this from a Slack thread on Gemini cost reconciliation, and a PostHog engineer directed the fix. I did not set an assignee because I could not verify their GitHub handle in session.
The flag is set only on generations that actually report cache reads, so generations without caching do not carry an extra property.
Along the way I checked whether the skew came from our own mapping and concluded it does not:
_extract_usage_from_metadatacopies both counts off a singleusage_metadataobject with no arithmetic. I also found a real asymmetry inmerge_usage_stats, where cumulative mode overwritesinput_tokenson every chunk but only writescache_read_input_tokenswhen it is above zero. That lets the two counts come from different chunks on a streaming call. It is not the cause of the case that prompted this PR, which is non-streaming, and I left it alone rather than widening the diff. It looks worth a separate look.Public artifact: no customer or session material reached this PR.
Created with PostHog from a Slack thread