diff --git a/posthog/ai/openai/openai.py b/posthog/ai/openai/openai.py index 09984745..deba0116 100644 --- a/posthog/ai/openai/openai.py +++ b/posthog/ai/openai/openai.py @@ -51,6 +51,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): self._original_embeddings = getattr(self, "embeddings", None) self._original_beta = getattr(self, "beta", None) self._original_responses = getattr(self, "responses", None) + self._original_audio = getattr(self, "audio", None) # Replace with wrapped versions (only if originals exist) if self._original_chat is not None: @@ -65,6 +66,9 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): if self._original_responses is not None: self.responses = WrappedResponses(self, self._original_responses) + if self._original_audio is not None: + self.audio = WrappedAudio(self, self._original_audio) + class WrappedResponses: """Wrapper for OpenAI responses that tracks usage in PostHog.""" @@ -534,6 +538,136 @@ def create( return response +class WrappedAudio: + """Wrapper for OpenAI audio that tracks usage in PostHog.""" + + def __init__(self, client: OpenAI, original_audio): + self._client = client + self._original = original_audio + + def __getattr__(self, name): + """Fallback to original audio object for any methods we don't explicitly handle.""" + return getattr(self._original, name) + + @property + def transcriptions(self): + return WrappedTranscriptions(self._client, self._original.transcriptions) + + +class WrappedTranscriptions: + """Wrapper for OpenAI audio transcriptions that tracks usage in PostHog.""" + + def __init__(self, client: OpenAI, original_transcriptions): + self._client = client + self._original = original_transcriptions + + def __getattr__(self, name): + """Fallback to original transcriptions object for any methods we don't explicitly handle.""" + return getattr(self._original, name) + + def create( + 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, + ): + """ + Create a transcription using OpenAI's 'audio.transcriptions.create' method, + and 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 Transcriptions API. + + Returns: + The response from OpenAI's audio.transcriptions.create call. + """ + if posthog_trace_id is None: + posthog_trace_id = str(uuid.uuid4()) + + # Extract file info before API call + file_obj = kwargs.get("file") + file_name = getattr(file_obj, "name", None) if file_obj else None + + start_time = time.time() + error = None + http_status = 200 + response = None + + try: + response = self._original.create(**kwargs) + except Exception as e: + error = e + http_status = getattr(e, "status_code", 0) + + end_time = time.time() + latency = end_time - start_time + + # Extract transcription output + output_text = getattr(response, "text", None) if response else None + + # Extract duration if available (verbose_json response format) + duration = getattr(response, "duration", None) if response else None + + # Build event properties + event_properties = { + "$ai_provider": "openai", + "$ai_model": kwargs.get("model"), + "$ai_input": with_privacy_mode( + self._client._ph_client, + posthog_privacy_mode, + file_name, + ), + "$ai_output_text": with_privacy_mode( + self._client._ph_client, + posthog_privacy_mode, + output_text, + ), + "$ai_http_status": http_status, + "$ai_latency": latency, + "$ai_trace_id": posthog_trace_id, + "$ai_base_url": str(self._client.base_url), + **(posthog_properties or {}), + } + + # Add optional properties + if kwargs.get("language"): + event_properties["$ai_language"] = kwargs.get("language") + + if duration is not None: + event_properties["$ai_audio_duration"] = duration + + # Add error properties if an error occurred + if error is not None: + event_properties["$ai_is_error"] = True + event_properties["$ai_error"] = str(error) + + if posthog_distinct_id is None: + event_properties["$process_person_profile"] = False + + # Capture event + if hasattr(self._client._ph_client, "capture"): + self._client._ph_client.capture( + distinct_id=posthog_distinct_id or posthog_trace_id, + event="$ai_transcription", + properties=event_properties, + groups=posthog_groups, + ) + + # Re-raise the error after capturing the event + if error is not None: + raise error + + return response + + class WrappedBeta: """Wrapper for OpenAI beta features that tracks usage in PostHog.""" diff --git a/posthog/ai/openai/openai_async.py b/posthog/ai/openai/openai_async.py index 77c9b260..c17d67ed 100644 --- a/posthog/ai/openai/openai_async.py +++ b/posthog/ai/openai/openai_async.py @@ -54,6 +54,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): self._original_embeddings = getattr(self, "embeddings", None) self._original_beta = getattr(self, "beta", None) self._original_responses = getattr(self, "responses", None) + self._original_audio = getattr(self, "audio", None) # Replace with wrapped versions (only if originals exist) if self._original_chat is not None: @@ -68,6 +69,9 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): if self._original_responses is not None: self.responses = WrappedResponses(self, self._original_responses) + if self._original_audio is not None: + self.audio = WrappedAudio(self, self._original_audio) + class WrappedResponses: """Async wrapper for OpenAI responses that tracks usage in PostHog.""" @@ -589,6 +593,136 @@ async def create( return response +class WrappedAudio: + """Async wrapper for OpenAI audio that tracks usage in PostHog.""" + + def __init__(self, client: AsyncOpenAI, original_audio): + self._client = client + self._original = original_audio + + def __getattr__(self, name): + """Fallback to original audio object for any methods we don't explicitly handle.""" + return getattr(self._original, name) + + @property + def transcriptions(self): + return WrappedTranscriptions(self._client, self._original.transcriptions) + + +class WrappedTranscriptions: + """Async wrapper for OpenAI audio transcriptions that tracks usage in PostHog.""" + + def __init__(self, client: AsyncOpenAI, original_transcriptions): + self._client = client + self._original = original_transcriptions + + def __getattr__(self, name): + """Fallback to original transcriptions object for any methods we don't explicitly handle.""" + return getattr(self._original, name) + + async def create( + 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, + ): + """ + Create a transcription using OpenAI's 'audio.transcriptions.create' method, + and 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 Transcriptions API. + + Returns: + The response from OpenAI's audio.transcriptions.create call. + """ + if posthog_trace_id is None: + posthog_trace_id = str(uuid.uuid4()) + + # Extract file info before API call + file_obj = kwargs.get("file") + file_name = getattr(file_obj, "name", None) if file_obj else None + + start_time = time.time() + error = None + http_status = 200 + response = None + + try: + response = await self._original.create(**kwargs) + except Exception as e: + error = e + http_status = getattr(e, "status_code", 0) + + end_time = time.time() + latency = end_time - start_time + + # Extract transcription output + output_text = getattr(response, "text", None) if response else None + + # Extract duration if available (verbose_json response format) + duration = getattr(response, "duration", None) if response else None + + # Build event properties + event_properties = { + "$ai_provider": "openai", + "$ai_model": kwargs.get("model"), + "$ai_input": with_privacy_mode( + self._client._ph_client, + posthog_privacy_mode, + file_name, + ), + "$ai_output_text": with_privacy_mode( + self._client._ph_client, + posthog_privacy_mode, + output_text, + ), + "$ai_http_status": http_status, + "$ai_latency": latency, + "$ai_trace_id": posthog_trace_id, + "$ai_base_url": str(self._client.base_url), + **(posthog_properties or {}), + } + + # Add optional properties + if kwargs.get("language"): + event_properties["$ai_language"] = kwargs.get("language") + + if duration is not None: + event_properties["$ai_audio_duration"] = duration + + # Add error properties if an error occurred + if error is not None: + event_properties["$ai_is_error"] = True + event_properties["$ai_error"] = str(error) + + if posthog_distinct_id is None: + event_properties["$process_person_profile"] = False + + # Capture event + if hasattr(self._client._ph_client, "capture"): + self._client._ph_client.capture( + distinct_id=posthog_distinct_id or posthog_trace_id, + event="$ai_transcription", + properties=event_properties, + groups=posthog_groups, + ) + + # Re-raise the error after capturing the event + if error is not None: + raise error + + return response + + class WrappedBeta: """Async wrapper for OpenAI beta features that tracks usage in PostHog.""" diff --git a/posthog/test/ai/openai/test_openai.py b/posthog/test/ai/openai/test_openai.py index 116ba2d1..18d35511 100644 --- a/posthog/test/ai/openai/test_openai.py +++ b/posthog/test/ai/openai/test_openai.py @@ -20,6 +20,7 @@ from openai.types.completion_usage import CompletionUsage from openai.types.create_embedding_response import CreateEmbeddingResponse, Usage from openai.types.embedding import Embedding + from openai.types.audio import Transcription from openai.types.responses import ( Response, ResponseOutputMessage, @@ -2132,3 +2133,253 @@ async def chunk_iterable(): props = call_args["properties"] assert props["$ai_model"] == "gpt-4o-mini-async-stored" + + +# Tests for audio transcriptions + + +@pytest.fixture +def mock_transcription_response(): + return Transcription(text="Hello world, this is a test transcription.") + + +@pytest.fixture +def mock_transcription_response_with_duration(): + return Transcription( + text="Hello world, this is a test transcription.", + duration=12.5, + ) + + +def test_transcription(mock_client, mock_transcription_response): + """Test basic transcription tracking.""" + from io import BytesIO + + mock_file = BytesIO(b"fake audio data") + mock_file.name = "test_audio.mp3" + + with patch( + "openai.resources.audio.transcriptions.Transcriptions.create", + return_value=mock_transcription_response, + ): + client = OpenAI(api_key="test-key", posthog_client=mock_client) + response = client.audio.transcriptions.create( + model="whisper-1", + file=mock_file, + posthog_distinct_id="test-id", + posthog_properties={"foo": "bar"}, + ) + + assert response == mock_transcription_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_transcription" + assert props["$ai_provider"] == "openai" + assert props["$ai_model"] == "whisper-1" + assert props["$ai_input"] == "test_audio.mp3" + assert props["$ai_output_text"] == "Hello world, this is a test transcription." + assert props["$ai_http_status"] == 200 + assert props["foo"] == "bar" + assert isinstance(props["$ai_latency"], float) + + +def test_transcription_with_duration( + mock_client, mock_transcription_response_with_duration +): + """Test transcription tracking with audio duration.""" + from io import BytesIO + + mock_file = BytesIO(b"fake audio data") + mock_file.name = "test_audio.mp3" + + with patch( + "openai.resources.audio.transcriptions.Transcriptions.create", + return_value=mock_transcription_response_with_duration, + ): + client = OpenAI(api_key="test-key", posthog_client=mock_client) + response = client.audio.transcriptions.create( + model="whisper-1", + file=mock_file, + posthog_distinct_id="test-id", + ) + + assert response == mock_transcription_response_with_duration + assert mock_client.capture.call_count == 1 + + call_args = mock_client.capture.call_args[1] + props = call_args["properties"] + + assert props["$ai_audio_duration"] == 12.5 + + +def test_transcription_with_language(mock_client, mock_transcription_response): + """Test transcription tracking with language parameter.""" + from io import BytesIO + + mock_file = BytesIO(b"fake audio data") + mock_file.name = "test_audio.mp3" + + with patch( + "openai.resources.audio.transcriptions.Transcriptions.create", + return_value=mock_transcription_response, + ): + client = OpenAI(api_key="test-key", posthog_client=mock_client) + response = client.audio.transcriptions.create( + model="whisper-1", + file=mock_file, + language="en", + posthog_distinct_id="test-id", + ) + + assert response == mock_transcription_response + assert mock_client.capture.call_count == 1 + + call_args = mock_client.capture.call_args[1] + props = call_args["properties"] + + assert props["$ai_language"] == "en" + + +def test_transcription_groups(mock_client, mock_transcription_response): + """Test transcription tracking with groups.""" + from io import BytesIO + + mock_file = BytesIO(b"fake audio data") + mock_file.name = "test_audio.mp3" + + with patch( + "openai.resources.audio.transcriptions.Transcriptions.create", + return_value=mock_transcription_response, + ): + client = OpenAI(api_key="test-key", posthog_client=mock_client) + response = client.audio.transcriptions.create( + model="whisper-1", + file=mock_file, + posthog_distinct_id="test-id", + posthog_groups={"company": "test_company"}, + ) + + assert response == mock_transcription_response + assert mock_client.capture.call_count == 1 + + call_args = mock_client.capture.call_args[1] + + assert call_args["groups"] == {"company": "test_company"} + + +def test_transcription_privacy_mode(mock_client, mock_transcription_response): + """Test transcription tracking with privacy mode enabled.""" + from io import BytesIO + + mock_file = BytesIO(b"fake audio data") + mock_file.name = "test_audio.mp3" + + with patch( + "openai.resources.audio.transcriptions.Transcriptions.create", + return_value=mock_transcription_response, + ): + client = OpenAI(api_key="test-key", posthog_client=mock_client) + response = client.audio.transcriptions.create( + model="whisper-1", + file=mock_file, + posthog_distinct_id="test-id", + posthog_privacy_mode=True, + ) + + assert response == mock_transcription_response + assert mock_client.capture.call_count == 1 + + call_args = mock_client.capture.call_args[1] + props = call_args["properties"] + + # Input and output should be redacted + assert props["$ai_input"] is None + assert props["$ai_output_text"] is None + + +@pytest.mark.asyncio +async def test_async_transcription(mock_client, mock_transcription_response): + """Test async transcription tracking.""" + from io import BytesIO + + mock_file = BytesIO(b"fake audio data") + mock_file.name = "test_audio.mp3" + + mock_create = AsyncMock(return_value=mock_transcription_response) + + with patch( + "openai.resources.audio.transcriptions.AsyncTranscriptions.create", + new=mock_create, + ): + client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client) + + response = await client.audio.transcriptions.create( + model="whisper-1", + file=mock_file, + posthog_distinct_id="test-id", + posthog_properties={"foo": "bar"}, + ) + + assert response == mock_transcription_response + assert mock_create.await_count == 1 + 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_transcription" + assert props["$ai_provider"] == "openai" + assert props["$ai_model"] == "whisper-1" + assert props["$ai_input"] == "test_audio.mp3" + assert props["$ai_output_text"] == "Hello world, this is a test transcription." + assert props["$ai_http_status"] == 200 + assert props["foo"] == "bar" + assert isinstance(props["$ai_latency"], float) + + +def test_transcription_error(mock_client): + """Test transcription error handling.""" + from io import BytesIO + + from openai import APIError + + mock_file = BytesIO(b"fake audio data") + mock_file.name = "test_audio.mp3" + + error = APIError( + message="Invalid audio file", + request=None, + body=None, + ) + error.status_code = 400 + + with patch( + "openai.resources.audio.transcriptions.Transcriptions.create", + side_effect=error, + ): + client = OpenAI(api_key="test-key", posthog_client=mock_client) + + with pytest.raises(APIError): + client.audio.transcriptions.create( + model="whisper-1", + file=mock_file, + posthog_distinct_id="test-id", + ) + + assert mock_client.capture.call_count == 1 + + call_args = mock_client.capture.call_args[1] + props = call_args["properties"] + + assert call_args["event"] == "$ai_transcription" + assert props["$ai_provider"] == "openai" + assert props["$ai_model"] == "whisper-1" + assert props["$ai_http_status"] == 400 + assert props["$ai_is_error"] is True + assert "Invalid audio file" in props["$ai_error"] + assert props["$ai_output_text"] is None