Skip to content

Commit 1574b1b

Browse files
fix: guard null token usage fields in OpenAI converter (#575)
1 parent 445357f commit 1574b1b

3 files changed

Lines changed: 72 additions & 14 deletions

File tree

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+
Fix OpenAI usage parsing when token detail fields are null

posthog/ai/openai/openai_converter.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -432,9 +432,9 @@ def extract_openai_usage_from_response(response: Any) -> TokenUsage:
432432
output_tokens=output_tokens,
433433
)
434434

435-
if cached_tokens > 0:
435+
if cached_tokens is not None and cached_tokens > 0:
436436
result["cache_read_input_tokens"] = cached_tokens
437-
if reasoning_tokens > 0:
437+
if reasoning_tokens is not None and reasoning_tokens > 0:
438438
result["reasoning_tokens"] = reasoning_tokens
439439

440440
web_search_count = extract_openai_web_search_count(response)
@@ -488,17 +488,17 @@ def extract_openai_usage_from_chunk(
488488
if hasattr(chunk.usage, "prompt_tokens_details") and hasattr(
489489
chunk.usage.prompt_tokens_details, "cached_tokens"
490490
):
491-
usage["cache_read_input_tokens"] = (
492-
chunk.usage.prompt_tokens_details.cached_tokens
493-
)
491+
cached = chunk.usage.prompt_tokens_details.cached_tokens
492+
if cached is not None:
493+
usage["cache_read_input_tokens"] = cached
494494

495495
# Handle reasoning tokens
496496
if hasattr(chunk.usage, "completion_tokens_details") and hasattr(
497497
chunk.usage.completion_tokens_details, "reasoning_tokens"
498498
):
499-
usage["reasoning_tokens"] = (
500-
chunk.usage.completion_tokens_details.reasoning_tokens
501-
)
499+
reasoning = chunk.usage.completion_tokens_details.reasoning_tokens
500+
if reasoning is not None:
501+
usage["reasoning_tokens"] = reasoning
502502

503503
# Capture raw usage metadata for backend processing
504504
# Serialize to dict here in the converter (not in utils)
@@ -522,17 +522,17 @@ def extract_openai_usage_from_chunk(
522522
if hasattr(response_usage, "input_tokens_details") and hasattr(
523523
response_usage.input_tokens_details, "cached_tokens"
524524
):
525-
usage["cache_read_input_tokens"] = (
526-
response_usage.input_tokens_details.cached_tokens
527-
)
525+
cached = response_usage.input_tokens_details.cached_tokens
526+
if cached is not None:
527+
usage["cache_read_input_tokens"] = cached
528528

529529
# Handle reasoning tokens
530530
if hasattr(response_usage, "output_tokens_details") and hasattr(
531531
response_usage.output_tokens_details, "reasoning_tokens"
532532
):
533-
usage["reasoning_tokens"] = (
534-
response_usage.output_tokens_details.reasoning_tokens
535-
)
533+
reasoning = response_usage.output_tokens_details.reasoning_tokens
534+
if reasoning is not None:
535+
usage["reasoning_tokens"] = reasoning
536536

537537
# Extract web search count from the complete response
538538
if hasattr(chunk, "response"):

posthog/test/ai/openai/test_openai.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,33 @@ def mock_openai_response_with_cached_tokens():
221221
)
222222

223223

224+
@pytest.fixture
225+
def mock_openai_response_with_null_token_details():
226+
return ChatCompletion(
227+
id="test",
228+
model="gpt-4",
229+
object="chat.completion",
230+
created=int(time.time()),
231+
choices=[
232+
Choice(
233+
finish_reason="stop",
234+
index=0,
235+
message=ChatCompletionMessage(
236+
content="Test response",
237+
role="assistant",
238+
),
239+
)
240+
],
241+
usage=CompletionUsage(
242+
completion_tokens=10,
243+
prompt_tokens=20,
244+
total_tokens=30,
245+
prompt_tokens_details={"cached_tokens": None},
246+
completion_tokens_details={"reasoning_tokens": None},
247+
),
248+
)
249+
250+
224251
@pytest.fixture
225252
def streaming_tool_call_chunks():
226253
return [
@@ -663,6 +690,32 @@ def test_cached_tokens(mock_client, mock_openai_response_with_cached_tokens):
663690
assert isinstance(props["$ai_latency"], float)
664691

665692

693+
def test_null_token_details_do_not_crash(
694+
mock_client, mock_openai_response_with_null_token_details
695+
):
696+
with patch(
697+
"openai.resources.chat.completions.Completions.create",
698+
return_value=mock_openai_response_with_null_token_details,
699+
):
700+
client = OpenAI(api_key="test-key", posthog_client=mock_client)
701+
response = client.chat.completions.create(
702+
model="gpt-4",
703+
messages=[{"role": "user", "content": "Hello"}],
704+
posthog_distinct_id="test-id",
705+
)
706+
707+
assert response == mock_openai_response_with_null_token_details
708+
assert mock_client.capture.call_count == 1
709+
710+
call_args = mock_client.capture.call_args[1]
711+
props = call_args["properties"]
712+
713+
assert props["$ai_input_tokens"] == 20
714+
assert props["$ai_output_tokens"] == 10
715+
assert "$ai_cache_read_input_tokens" not in props
716+
assert "$ai_reasoning_tokens" not in props
717+
718+
666719
def test_tool_calls(mock_client, mock_openai_response_with_tool_calls):
667720
with patch(
668721
"openai.resources.chat.completions.Completions.create",

0 commit comments

Comments
 (0)