diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ed7d5a..7ca397b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.7.0 - 2025-06-10 + +- feat: add support for parse endpoint in responses API (no longer beta) + ## 4.6.2 - 2025-06-09 - fix: replace `import posthog` with direct method imports diff --git a/posthog/ai/openai/openai.py b/posthog/ai/openai/openai.py index 92e8b4a9..d86fecfc 100644 --- a/posthog/ai/openai/openai.py +++ b/posthog/ai/openai/openai.py @@ -230,6 +230,42 @@ def _capture_streaming_event( groups=posthog_groups, ) + def parse( + self, + posthog_distinct_id: Optional[str] = None, + posthog_trace_id: Optional[str] = None, + posthog_properties: Optional[Dict[str, Any]] = None, + posthog_privacy_mode: bool = False, + posthog_groups: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ): + """ + Parse structured output using OpenAI's 'responses.parse' method, but also track usage in PostHog. + + Args: + posthog_distinct_id: Optional ID to associate with the usage event. + posthog_trace_id: Optional trace UUID for linking events. + posthog_properties: Optional dictionary of extra properties to include in the event. + posthog_privacy_mode: Whether to anonymize the input and output. + posthog_groups: Optional dictionary of groups to associate with the event. + **kwargs: Any additional parameters for the OpenAI Responses Parse API. + + Returns: + The response from OpenAI's responses.parse call. + """ + return call_llm_and_track_usage( + posthog_distinct_id, + self._client._ph_client, + "openai", + posthog_trace_id, + posthog_properties, + posthog_privacy_mode, + posthog_groups, + self._client.base_url, + self._original.parse, + **kwargs, + ) + class WrappedChat: """Wrapper for OpenAI chat that tracks usage in PostHog.""" diff --git a/posthog/ai/openai/openai_async.py b/posthog/ai/openai/openai_async.py index 2a3487db..76c32a74 100644 --- a/posthog/ai/openai/openai_async.py +++ b/posthog/ai/openai/openai_async.py @@ -230,6 +230,42 @@ async def _capture_streaming_event( groups=posthog_groups, ) + async def parse( + self, + posthog_distinct_id: Optional[str] = None, + posthog_trace_id: Optional[str] = None, + posthog_properties: Optional[Dict[str, Any]] = None, + posthog_privacy_mode: bool = False, + posthog_groups: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ): + """ + Parse structured output using OpenAI's 'responses.parse' method, but also track usage in PostHog. + + Args: + posthog_distinct_id: Optional ID to associate with the usage event. + posthog_trace_id: Optional trace UUID for linking events. + posthog_properties: Optional dictionary of extra properties to include in the event. + posthog_privacy_mode: Whether to anonymize the input and output. + posthog_groups: Optional dictionary of groups to associate with the event. + **kwargs: Any additional parameters for the OpenAI Responses Parse API. + + Returns: + The response from OpenAI's responses.parse call. + """ + return await call_llm_and_track_usage_async( + posthog_distinct_id, + self._client._ph_client, + "openai", + posthog_trace_id, + posthog_properties, + posthog_privacy_mode, + posthog_groups, + self._client.base_url, + self._original.parse, + **kwargs, + ) + class WrappedChat: """Async wrapper for OpenAI chat that tracks usage in PostHog.""" diff --git a/posthog/test/ai/openai/test_openai.py b/posthog/test/ai/openai/test_openai.py index 1936f630..1c99363b 100644 --- a/posthog/test/ai/openai/test_openai.py +++ b/posthog/test/ai/openai/test_openai.py @@ -26,6 +26,11 @@ ResponseOutputMessage, ResponseOutputText, ResponseUsage, + ParsedResponse, + ) + from openai.types.responses.parsed_response import ( + ParsedResponseOutputMessage, + ParsedResponseOutputText, ) from posthog.ai.openai import OpenAI @@ -115,6 +120,59 @@ def mock_openai_response_with_responses_api(): ) +@pytest.fixture +def mock_parsed_response(): + return ParsedResponse( + id="test", + model="gpt-4o-2024-08-06", + object="response", + created_at=1741476542, + status="completed", + error=None, + incomplete_details=None, + instructions=None, + max_output_tokens=None, + tools=[], + tool_choice="auto", + output=[ + ParsedResponseOutputMessage( + id="msg_123", + type="message", + role="assistant", + status="completed", + content=[ + ParsedResponseOutputText( + type="output_text", + text='{"name": "Science Fair", "date": "Friday", "participants": ["Alice", "Bob"]}', + annotations=[], + parsed={ + "name": "Science Fair", + "date": "Friday", + "participants": ["Alice", "Bob"], + }, + ) + ], + ) + ], + output_parsed={ + "name": "Science Fair", + "date": "Friday", + "participants": ["Alice", "Bob"], + }, + parallel_tool_calls=True, + previous_response_id=None, + usage=ResponseUsage( + input_tokens=15, + output_tokens=20, + input_tokens_details={"prompt_tokens": 15, "cached_tokens": 0}, + output_tokens_details={"reasoning_tokens": 5}, + total_tokens=35, + ), + user=None, + metadata={}, + ) + + @pytest.fixture def mock_embedding_response(): return CreateEmbeddingResponse( @@ -646,3 +704,73 @@ def test_responses_api(mock_client, mock_openai_response_with_responses_api): assert props["$ai_http_status"] == 200 assert props["foo"] == "bar" assert isinstance(props["$ai_latency"], float) + + +def test_responses_parse(mock_client, mock_parsed_response): + with patch( + "openai.resources.responses.Responses.parse", + return_value=mock_parsed_response, + ): + client = OpenAI(api_key="test-key", posthog_client=mock_client) + response = client.responses.parse( + model="gpt-4o-2024-08-06", + input=[ + {"role": "system", "content": "Extract the event information."}, + { + "role": "user", + "content": "Alice and Bob are going to a science fair on Friday.", + }, + ], + text={ + "format": { + "type": "json_schema", + "json_schema": { + "name": "event", + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "date": {"type": "string"}, + "participants": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": ["name", "date", "participants"], + }, + }, + } + }, + posthog_distinct_id="test-id", + posthog_properties={"foo": "bar"}, + ) + + assert response == mock_parsed_response + assert mock_client.capture.call_count == 1 + + call_args = mock_client.capture.call_args[1] + props = call_args["properties"] + + assert call_args["distinct_id"] == "test-id" + assert call_args["event"] == "$ai_generation" + assert props["$ai_provider"] == "openai" + assert props["$ai_model"] == "gpt-4o-2024-08-06" + assert props["$ai_input"] == [ + {"role": "system", "content": "Extract the event information."}, + { + "role": "user", + "content": "Alice and Bob are going to a science fair on Friday.", + }, + ] + assert props["$ai_output_choices"] == [ + { + "role": "assistant", + "content": '{"name": "Science Fair", "date": "Friday", "participants": ["Alice", "Bob"]}', + } + ] + assert props["$ai_input_tokens"] == 15 + assert props["$ai_output_tokens"] == 20 + assert props["$ai_reasoning_tokens"] == 5 + assert props["$ai_http_status"] == 200 + assert props["foo"] == "bar" + assert isinstance(props["$ai_latency"], float) diff --git a/posthog/version.py b/posthog/version.py index 1791721a..9156f536 100644 --- a/posthog/version.py +++ b/posthog/version.py @@ -1,4 +1,4 @@ -VERSION = "4.6.2" +VERSION = "4.7.0" if __name__ == "__main__": print(VERSION, end="") # noqa: T201