Skip to content

Commit 9444ec5

Browse files
authored
fix(ai): omit token counts the provider never reported (#906)
* fix(ai): omit token counts the provider never reported Every integration wrote $ai_input_tokens: 0 and $ai_output_tokens: 0 when the provider never reported usage, so an interrupted stream priced as a free call downstream. Token counts are now written only when they trace back to a provider report: the shared capture paths, both embedding events, the LangChain callback and the Agents and Claude Agent SDK processors omit unreported counts, and the stream accumulators and response extractors no longer seed at zero. A zero the provider reported is still sent as 0, so zero keeps meaning a real report of nothing. LangChain cache and reasoning counts follow the same rule instead of coalescing to 0, and the Agents processor's $ai_total_tokens is the sum of the reported sides, omitted when neither side reported. Mirrors the posthog-js change (PostHog/posthog-js#4664). Ingestion distinguishes absent from zero since PostHog/posthog#90211 and older ingestion coalesces absent to 0 at read time, so this ships independently. Generated-By: PostHog Desktop Task-Id: ec0cc27f-fc40-4994-b153-2bfe74de1edf * chore(ai): update public API snapshot for Optional embedding count extract_gemini_embedding_token_count now returns Optional[int], returning None when no embedding carried a token count. Regenerated with make public_api_snapshot. Generated-By: PostHog Desktop Task-Id: ec0cc27f-fc40-4994-b153-2bfe74de1edf
1 parent 2e7c73b commit 9444ec5

19 files changed

Lines changed: 260 additions & 90 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: patch
3+
---
4+
5+
Omit `$ai_input_tokens` and `$ai_output_tokens` when the provider never reported usage, instead of sending `0`, so an interrupted stream no longer looks like a free call. A zero reported by the provider is still sent, and zero keeps meaning a real report of nothing. Covers the OpenAI, Anthropic, Gemini, LangChain, OpenAI Agents and Claude Agent SDK integrations.

posthog/ai/anthropic/_anthropic_stream.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class _AnthropicStreamAccumulator:
1515
"""Accumulates sync-neutral capture state from Anthropic stream events."""
1616

1717
def __init__(self) -> None:
18-
self.usage_stats: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0)
18+
self.usage_stats: TokenUsage = TokenUsage()
1919
self.accumulated_content = ""
2020
self.content_blocks: List[StreamingContentBlock] = []
2121
self.tools_in_progress: Dict[str, ToolInProgress] = {}

posthog/ai/anthropic/anthropic_converter.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -252,13 +252,16 @@ def extract_anthropic_usage_from_response(response: Any) -> TokenUsage:
252252
Returns:
253253
TokenUsage with standardized usage
254254
"""
255-
if not hasattr(response, "usage"):
256-
return TokenUsage(input_tokens=0, output_tokens=0)
257-
258-
result = TokenUsage(
259-
input_tokens=getattr(response.usage, "input_tokens", 0),
260-
output_tokens=getattr(response.usage, "output_tokens", 0),
261-
)
255+
if getattr(response, "usage", None) is None:
256+
return TokenUsage()
257+
258+
result = TokenUsage()
259+
input_tokens = getattr(response.usage, "input_tokens", None)
260+
if input_tokens is not None:
261+
result["input_tokens"] = input_tokens
262+
output_tokens = getattr(response.usage, "output_tokens", None)
263+
if output_tokens is not None:
264+
result["output_tokens"] = output_tokens
262265

263266
if hasattr(response.usage, "cache_read_input_tokens"):
264267
cache_read = response.usage.cache_read_input_tokens

posthog/ai/claude_agent_sdk/processor.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,12 @@ class _GenerationData:
4545
"""Data accumulated for a single LLM generation (one API call)."""
4646

4747
model: Optional[str] = None
48-
input_tokens: int = 0
49-
output_tokens: int = 0
50-
cache_read_input_tokens: int = 0
51-
cache_creation_input_tokens: int = 0
48+
# None when the provider never reported a count: absent means unknown,
49+
# 0 is a report of nothing.
50+
input_tokens: Optional[int] = None
51+
output_tokens: Optional[int] = None
52+
cache_read_input_tokens: Optional[int] = None
53+
cache_creation_input_tokens: Optional[int] = None
5254
raw_usage: Optional[Dict[str, Any]] = None
5355
start_time: float = 0.0
5456
end_time: float = 0.0
@@ -79,13 +81,11 @@ def process_stream_event(self, event: "StreamEvent") -> None:
7981
message = raw.get("message", {})
8082
self._current.model = message.get("model")
8183
usage = message.get("usage", {})
82-
self._current.input_tokens = usage.get("input_tokens", 0)
83-
self._current.output_tokens = usage.get("output_tokens", 0)
84-
self._current.cache_read_input_tokens = usage.get(
85-
"cache_read_input_tokens", 0
86-
)
84+
self._current.input_tokens = usage.get("input_tokens")
85+
self._current.output_tokens = usage.get("output_tokens")
86+
self._current.cache_read_input_tokens = usage.get("cache_read_input_tokens")
8787
self._current.cache_creation_input_tokens = usage.get(
88-
"cache_creation_input_tokens", 0
88+
"cache_creation_input_tokens"
8989
)
9090
self._current.raw_usage = dict(usage)
9191

@@ -410,8 +410,16 @@ def _emit_generation(
410410
"$ai_provider": "anthropic",
411411
"$ai_framework": "claude-agent-sdk",
412412
"$ai_model": gen.model,
413-
"$ai_input_tokens": gen.input_tokens,
414-
"$ai_output_tokens": gen.output_tokens,
413+
**(
414+
{"$ai_input_tokens": gen.input_tokens}
415+
if gen.input_tokens is not None
416+
else {}
417+
),
418+
**(
419+
{"$ai_output_tokens": gen.output_tokens}
420+
if gen.output_tokens is not None
421+
else {}
422+
),
415423
"$ai_latency": latency,
416424
**extra_props,
417425
}
@@ -472,8 +480,16 @@ def _emit_generation_from_result(
472480
"$ai_provider": "anthropic",
473481
"$ai_framework": "claude-agent-sdk",
474482
"$ai_model": model,
475-
"$ai_input_tokens": usage.get("input_tokens", 0),
476-
"$ai_output_tokens": usage.get("output_tokens", 0),
483+
**(
484+
{"$ai_input_tokens": usage["input_tokens"]}
485+
if usage.get("input_tokens") is not None
486+
else {}
487+
),
488+
**(
489+
{"$ai_output_tokens": usage["output_tokens"]}
490+
if usage.get("output_tokens") is not None
491+
else {}
492+
),
477493
"$ai_latency": result.duration_api_ms / 1000.0
478494
if result.duration_api_ms
479495
else 0,
@@ -494,8 +510,8 @@ def _emit_generation_from_result(
494510
finalize_ai_content(output_choices, self._client),
495511
)
496512

497-
cache_read = usage.get("cache_read_input_tokens", 0)
498-
cache_creation = usage.get("cache_creation_input_tokens", 0)
513+
cache_read = usage.get("cache_read_input_tokens")
514+
cache_creation = usage.get("cache_creation_input_tokens")
499515
if cache_read:
500516
properties["$ai_cache_read_input_tokens"] = cache_read
501517
if cache_creation:

posthog/ai/gemini/_shared.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,9 @@ def _capture_embedding_outcome(
195195
error: Optional[Exception],
196196
latency: float,
197197
) -> None:
198-
input_tokens = extract_gemini_embedding_token_count(response) if response else 0
198+
input_tokens = (
199+
extract_gemini_embedding_token_count(response) if response else None
200+
)
199201
event_properties = {
200202
"$ai_provider": "gemini",
201203
"$ai_model": model,
@@ -207,7 +209,9 @@ def _capture_embedding_outcome(
207209
"$ai_http_status": (
208210
getattr(error, "status_code", 0) if error is not None else 200
209211
),
210-
"$ai_input_tokens": input_tokens,
212+
# Omitted when the provider never reported a count: absent means
213+
# unknown, 0 is a report of nothing.
214+
**({"$ai_input_tokens": input_tokens} if input_tokens is not None else {}),
211215
"$ai_latency": latency,
212216
"$ai_trace_id": trace_id,
213217
"$ai_base_url": self._base_url,

posthog/ai/gemini/gemini.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ def _generate_content_streaming(
216216
**kwargs: Any,
217217
):
218218
start_time = time.time()
219-
usage_stats: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0)
219+
usage_stats: TokenUsage = TokenUsage()
220220
accumulated_content = []
221221
stop_reason: Optional[str] = None
222222

posthog/ai/gemini/gemini_async.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ async def _generate_content_streaming(
217217
**kwargs: Any,
218218
):
219219
start_time = time.time()
220-
usage_stats: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0)
220+
usage_stats: TokenUsage = TokenUsage()
221221
accumulated_content = []
222222
stop_reason: Optional[str] = None
223223

posthog/ai/gemini/gemini_converter.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,7 @@ def extract_gemini_usage_from_response(response: Any) -> TokenUsage:
546546
TokenUsage with standardized usage statistics
547547
"""
548548
if not hasattr(response, "usage_metadata") or not response.usage_metadata:
549-
return TokenUsage(input_tokens=0, output_tokens=0)
549+
return TokenUsage()
550550

551551
usage = _extract_usage_from_metadata(response.usage_metadata)
552552

@@ -715,17 +715,19 @@ def format_gemini_streaming_output(
715715
return [{"role": "assistant", "content": [{"type": "text", "text": ""}]}]
716716

717717

718-
def extract_gemini_embedding_token_count(response) -> int:
718+
def extract_gemini_embedding_token_count(response) -> Optional[int]:
719719
"""
720720
Extract total token count from a Gemini embed_content response.
721721
Token counts are only available per-embedding via Vertex AI's statistics.token_count.
722-
Returns 0 if no token counts are available.
722+
Returns None when no embedding carried a token count.
723723
"""
724724
total = 0
725+
reported = False
725726
if hasattr(response, "embeddings") and response.embeddings:
726727
for embedding in response.embeddings:
727728
if hasattr(embedding, "statistics") and embedding.statistics:
728729
token_count = getattr(embedding.statistics, "token_count", None)
729730
if token_count is not None:
730731
total += int(token_count)
731-
return total
732+
reported = True
733+
return total if reported else None

posthog/ai/langchain/callbacks.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -664,11 +664,16 @@ def _capture_generation(
664664
else:
665665
# Add usage
666666
usage = _parse_usage(output, run.provider, run.model)
667-
event_properties["$ai_input_tokens"] = usage.input_tokens
668-
event_properties["$ai_output_tokens"] = usage.output_tokens
669-
event_properties["$ai_cache_creation_input_tokens"] = (
670-
usage.cache_write_tokens
671-
)
667+
# Omitted when the provider never reported a count: absent means
668+
# unknown, 0 is a report of nothing.
669+
if usage.input_tokens is not None:
670+
event_properties["$ai_input_tokens"] = usage.input_tokens
671+
if usage.output_tokens is not None:
672+
event_properties["$ai_output_tokens"] = usage.output_tokens
673+
if usage.cache_write_tokens is not None:
674+
event_properties["$ai_cache_creation_input_tokens"] = (
675+
usage.cache_write_tokens
676+
)
672677
if (
673678
usage.cache_write_5m_tokens is not None
674679
and usage.cache_write_1h_tokens is not None
@@ -679,8 +684,12 @@ def _capture_generation(
679684
event_properties["$ai_cache_creation_1h_input_tokens"] = (
680685
usage.cache_write_1h_tokens
681686
)
682-
event_properties["$ai_cache_read_input_tokens"] = usage.cache_read_tokens
683-
event_properties["$ai_reasoning_tokens"] = usage.reasoning_tokens
687+
if usage.cache_read_tokens is not None:
688+
event_properties["$ai_cache_read_input_tokens"] = (
689+
usage.cache_read_tokens
690+
)
691+
if usage.reasoning_tokens is not None:
692+
event_properties["$ai_reasoning_tokens"] = usage.reasoning_tokens
684693

685694
# Generation results
686695
generation_result = output.generations[-1]
@@ -875,7 +884,7 @@ def _parse_usage_model(
875884
}
876885
normalized_usage = ModelUsage(
877886
**{
878-
dataclass_key: parsed_usage.get(mapped_key) or 0
887+
dataclass_key: parsed_usage.get(mapped_key)
879888
for mapped_key, dataclass_key in field_mapping.items()
880889
},
881890
cache_write_5m_tokens=parsed_usage.get("cache_write_5m"),

posthog/ai/openai/_embeddings.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def _capture_embedding_event(
1818
) -> None:
1919
"""Build and capture telemetry shared by sync and async embedding wrappers."""
2020
usage = getattr(response, "usage", None)
21-
input_tokens = getattr(usage, "prompt_tokens", 0) if usage else 0
21+
input_tokens = getattr(usage, "prompt_tokens", None) if usage else None
2222

2323
event_properties = {
2424
"$ai_provider": "openai",
@@ -29,7 +29,9 @@ def _capture_embedding_event(
2929
finalize_ai_content(request_kwargs.get("input"), posthog_client),
3030
),
3131
"$ai_http_status": 200,
32-
"$ai_input_tokens": input_tokens,
32+
# Omitted when the provider never reported a count: absent means
33+
# unknown, 0 is a report of nothing.
34+
**({"$ai_input_tokens": input_tokens} if input_tokens is not None else {}),
3335
"$ai_latency": latency,
3436
"$ai_trace_id": trace_id,
3537
"$ai_base_url": str(base_url),

0 commit comments

Comments
 (0)