From caf4168695837b349de46778134fb352a2a310c8 Mon Sep 17 00:00:00 2001 From: michael-ciridae Date: Thu, 14 May 2026 09:30:29 -0400 Subject: [PATCH 1/3] fix: guard null token usage fields in OpenAI converter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extract_openai_usage_from_response` crashes with TypeError when the OpenAI API response includes null values for `cached_tokens` or `reasoning_tokens` in token detail objects. The `hasattr()` checks pass because the attributes exist — they're just None. This is common with OpenAI-compatible proxies/gateways that include the detail objects but set individual fields to null when not applicable. Add None checks before `> 0` comparisons and before assigning to the usage dict in streaming paths. Fixes #574 --- posthog/ai/openai/openai_converter.py | 28 +++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/posthog/ai/openai/openai_converter.py b/posthog/ai/openai/openai_converter.py index 5cb1a148..95090723 100644 --- a/posthog/ai/openai/openai_converter.py +++ b/posthog/ai/openai/openai_converter.py @@ -432,9 +432,9 @@ def extract_openai_usage_from_response(response: Any) -> TokenUsage: output_tokens=output_tokens, ) - if cached_tokens > 0: + if cached_tokens is not None and cached_tokens > 0: result["cache_read_input_tokens"] = cached_tokens - if reasoning_tokens > 0: + if reasoning_tokens is not None and reasoning_tokens > 0: result["reasoning_tokens"] = reasoning_tokens web_search_count = extract_openai_web_search_count(response) @@ -488,17 +488,17 @@ def extract_openai_usage_from_chunk( if hasattr(chunk.usage, "prompt_tokens_details") and hasattr( chunk.usage.prompt_tokens_details, "cached_tokens" ): - usage["cache_read_input_tokens"] = ( - chunk.usage.prompt_tokens_details.cached_tokens - ) + cached = chunk.usage.prompt_tokens_details.cached_tokens + if cached is not None: + usage["cache_read_input_tokens"] = cached # Handle reasoning tokens if hasattr(chunk.usage, "completion_tokens_details") and hasattr( chunk.usage.completion_tokens_details, "reasoning_tokens" ): - usage["reasoning_tokens"] = ( - chunk.usage.completion_tokens_details.reasoning_tokens - ) + reasoning = chunk.usage.completion_tokens_details.reasoning_tokens + if reasoning is not None: + usage["reasoning_tokens"] = reasoning # Capture raw usage metadata for backend processing # Serialize to dict here in the converter (not in utils) @@ -522,17 +522,17 @@ def extract_openai_usage_from_chunk( if hasattr(response_usage, "input_tokens_details") and hasattr( response_usage.input_tokens_details, "cached_tokens" ): - usage["cache_read_input_tokens"] = ( - response_usage.input_tokens_details.cached_tokens - ) + cached = response_usage.input_tokens_details.cached_tokens + if cached is not None: + usage["cache_read_input_tokens"] = cached # Handle reasoning tokens if hasattr(response_usage, "output_tokens_details") and hasattr( response_usage.output_tokens_details, "reasoning_tokens" ): - usage["reasoning_tokens"] = ( - response_usage.output_tokens_details.reasoning_tokens - ) + reasoning = response_usage.output_tokens_details.reasoning_tokens + if reasoning is not None: + usage["reasoning_tokens"] = reasoning # Extract web search count from the complete response if hasattr(chunk, "response"): From 6ff22412b620055d3817afef32e43a419452c830 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 21 May 2026 15:08:32 +0200 Subject: [PATCH 2/3] test: cover null OpenAI token details --- posthog/test/ai/openai/test_openai.py | 53 +++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/posthog/test/ai/openai/test_openai.py b/posthog/test/ai/openai/test_openai.py index acede645..2f35329c 100644 --- a/posthog/test/ai/openai/test_openai.py +++ b/posthog/test/ai/openai/test_openai.py @@ -221,6 +221,33 @@ def mock_openai_response_with_cached_tokens(): ) +@pytest.fixture +def mock_openai_response_with_null_token_details(): + return ChatCompletion( + id="test", + model="gpt-4", + object="chat.completion", + created=int(time.time()), + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Test response", + role="assistant", + ), + ) + ], + usage=CompletionUsage( + completion_tokens=10, + prompt_tokens=20, + total_tokens=30, + prompt_tokens_details={"cached_tokens": None}, + completion_tokens_details={"reasoning_tokens": None}, + ), + ) + + @pytest.fixture def streaming_tool_call_chunks(): return [ @@ -663,6 +690,32 @@ def test_cached_tokens(mock_client, mock_openai_response_with_cached_tokens): assert isinstance(props["$ai_latency"], float) +def test_null_token_details_do_not_crash( + mock_client, mock_openai_response_with_null_token_details +): + with patch( + "openai.resources.chat.completions.Completions.create", + return_value=mock_openai_response_with_null_token_details, + ): + client = OpenAI(api_key="test-key", posthog_client=mock_client) + response = client.chat.completions.create( + model="gpt-4", + messages=[{"role": "user", "content": "Hello"}], + posthog_distinct_id="test-id", + ) + + assert response == mock_openai_response_with_null_token_details + assert mock_client.capture.call_count == 1 + + call_args = mock_client.capture.call_args[1] + props = call_args["properties"] + + assert props["$ai_input_tokens"] == 20 + assert props["$ai_output_tokens"] == 10 + assert "$ai_cache_read_input_tokens" not in props + assert "$ai_reasoning_tokens" not in props + + def test_tool_calls(mock_client, mock_openai_response_with_tool_calls): with patch( "openai.resources.chat.completions.Completions.create", From f33faf589f6605048c4626ac9bfb54d57c3acfed Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 21 May 2026 15:09:15 +0200 Subject: [PATCH 3/3] chore: add changeset for OpenAI null token details --- .sampo/changesets/noble-thunderbearer-vellamo.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .sampo/changesets/noble-thunderbearer-vellamo.md diff --git a/.sampo/changesets/noble-thunderbearer-vellamo.md b/.sampo/changesets/noble-thunderbearer-vellamo.md new file mode 100644 index 00000000..91ea1159 --- /dev/null +++ b/.sampo/changesets/noble-thunderbearer-vellamo.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Fix OpenAI usage parsing when token detail fields are null