Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/noble-thunderbearer-vellamo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: patch
---

Fix OpenAI usage parsing when token detail fields are null
28 changes: 14 additions & 14 deletions posthog/ai/openai/openai_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Comment thread
marandaneto marked this conversation as resolved.

# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
Expand All @@ -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"):
Expand Down
53 changes: 53 additions & 0 deletions posthog/test/ai/openai/test_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -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",
Expand Down
Loading