From 2e2ce15c4ba03e1d6f15ce0400a18e574ef81ab4 Mon Sep 17 00:00:00 2001 From: Marco Gancitano Date: Thu, 30 Jul 2026 18:44:25 -0400 Subject: [PATCH 1/2] feat(ai): add posthog_provider_override to the OpenAI wrapper posthog.ai.openai.OpenAI is also used against OpenAI-compatible endpoints (DeepSeek, Groq, Mistral, Together, Fireworks, xAI, Perplexity, Ollama, Cerebras, gateways) via a custom base_url, but always reported $ai_provider: "openai", breaking cost attribution for those calls. Adds a per-call posthog_provider_override parameter, matching the JS SDK's posthogProviderOverride, across chat completions, the Responses API, .parse(), and embeddings, for sync, async, and streaming. Omitting it leaves $ai_provider as "openai" exactly as before. Scoped to posthog/ai/openai/ only: the override is folded into posthog_properties (which every capture path already merges in last) rather than touching the provider value used for response-format selection, so the shared posthog/ai/utils.py helpers and the Anthropic/Gemini wrappers are untouched. Co-Authored-By: Claude Opus 5 --- .sampo/changesets/openai-provider-override.md | 5 + posthog/ai/openai/openai.py | 59 +++- posthog/ai/openai/openai_async.py | 59 +++- posthog/ai/openai/wrapper_utils.py | 28 ++ posthog/test/ai/openai/test_openai.py | 293 ++++++++++++++++++ 5 files changed, 440 insertions(+), 4 deletions(-) create mode 100644 .sampo/changesets/openai-provider-override.md diff --git a/.sampo/changesets/openai-provider-override.md b/.sampo/changesets/openai-provider-override.md new file mode 100644 index 00000000..6fb28e66 --- /dev/null +++ b/.sampo/changesets/openai-provider-override.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +`posthog.ai.openai.OpenAI` / `AsyncOpenAI` now accept a per-call `posthog_provider_override` argument. The wrapper is commonly pointed at OpenAI-compatible endpoints (DeepSeek, Groq, Mistral, Together, Fireworks, xAI, Perplexity, Ollama, Cerebras, and various gateways) via a custom `base_url`, but always reported `$ai_provider: "openai"`, which breaks PostHog's cost attribution for those calls. Passing `posthog_provider_override="deepseek"` (for example) sets `$ai_provider` on the emitted event without changing how the OpenAI-shaped response is parsed. Omitting it leaves `$ai_provider` as `"openai"`, exactly as before. Covers chat completions, the Responses API, `.parse()`, and embeddings, across sync, async, and streaming calls. diff --git a/posthog/ai/openai/openai.py b/posthog/ai/openai/openai.py index c307be3f..35bb0c2d 100644 --- a/posthog/ai/openai/openai.py +++ b/posthog/ai/openai/openai.py @@ -27,7 +27,10 @@ ) from posthog.client import Client as PostHogClient from posthog import setup -from posthog.ai.openai.wrapper_utils import _OpenAIWrapperResource +from posthog.ai.openai.wrapper_utils import ( + _OpenAIWrapperResource, + merge_provider_override, +) class OpenAI(openai.OpenAI): @@ -76,6 +79,7 @@ def _parse_and_track( posthog_properties: Optional[Dict[str, Any]], posthog_privacy_mode: bool, posthog_groups: Optional[Dict[str, Any]], + posthog_provider_override: Optional[str] = None, **kwargs: Any, ): return call_llm_and_track_usage( @@ -83,7 +87,7 @@ def _parse_and_track( wrapper._client._ph_client, "openai", posthog_trace_id, - posthog_properties, + merge_provider_override(posthog_properties, posthog_provider_override), posthog_privacy_mode, posthog_groups, wrapper._client.base_url, @@ -102,6 +106,7 @@ def create( posthog_properties: Optional[Dict[str, Any]] = None, posthog_privacy_mode: bool = False, posthog_groups: Optional[Dict[str, Any]] = None, + posthog_provider_override: Optional[str] = None, **kwargs: Any, ): """ @@ -113,6 +118,11 @@ def create( posthog_properties: Additional properties to include with the usage event. posthog_privacy_mode: Whether to redact captured input and output. posthog_groups: Optional PostHog groups to associate with the event. + posthog_provider_override: Optional override for the ``$ai_provider`` + reported on the usage event. Useful when this client is pointed at + an OpenAI-compatible endpoint (e.g. DeepSeek, Groq) via a custom + ``base_url``, so cost attribution matches the real provider. + Defaults to ``"openai"`` when omitted. **kwargs: Arguments passed to OpenAI's ``responses.create`` API. Returns: @@ -121,6 +131,10 @@ def create( if posthog_trace_id is None: posthog_trace_id = str(uuid.uuid4()) + posthog_properties = merge_provider_override( + posthog_properties, posthog_provider_override + ) + if kwargs.get("stream", False): return self._create_streaming( posthog_distinct_id, @@ -274,6 +288,7 @@ def parse( posthog_properties: Optional[Dict[str, Any]] = None, posthog_privacy_mode: bool = False, posthog_groups: Optional[Dict[str, Any]] = None, + posthog_provider_override: Optional[str] = None, **kwargs: Any, ): """ @@ -285,6 +300,11 @@ def parse( 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. + posthog_provider_override: Optional override for the ``$ai_provider`` + reported on the usage event. Useful when this client is pointed at + an OpenAI-compatible endpoint (e.g. DeepSeek, Groq) via a custom + ``base_url``, so cost attribution matches the real provider. + Defaults to ``"openai"`` when omitted. **kwargs: Any additional parameters for the OpenAI Responses Parse API. Returns: @@ -297,6 +317,7 @@ def parse( posthog_properties, posthog_privacy_mode, posthog_groups, + posthog_provider_override, **kwargs, ) @@ -320,6 +341,7 @@ def parse( posthog_properties: Optional[Dict[str, Any]] = None, posthog_privacy_mode: bool = False, posthog_groups: Optional[Dict[str, Any]] = None, + posthog_provider_override: Optional[str] = None, **kwargs: Any, ): """ @@ -331,6 +353,11 @@ def parse( posthog_properties: Additional properties to include with the usage event. posthog_privacy_mode: Whether to redact captured input and output. posthog_groups: Optional PostHog groups to associate with the event. + posthog_provider_override: Optional override for the ``$ai_provider`` + reported on the usage event. Useful when this client is pointed at + an OpenAI-compatible endpoint (e.g. DeepSeek, Groq) via a custom + ``base_url``, so cost attribution matches the real provider. + Defaults to ``"openai"`` when omitted. **kwargs: Arguments passed to OpenAI's ``chat.completions.parse`` API. Returns: @@ -343,6 +370,7 @@ def parse( posthog_properties, posthog_privacy_mode, posthog_groups, + posthog_provider_override, **kwargs, ) @@ -353,6 +381,7 @@ def create( posthog_properties: Optional[Dict[str, Any]] = None, posthog_privacy_mode: bool = False, posthog_groups: Optional[Dict[str, Any]] = None, + posthog_provider_override: Optional[str] = None, **kwargs: Any, ): """ @@ -364,6 +393,11 @@ def create( posthog_properties: Additional properties to include with the usage event. posthog_privacy_mode: Whether to redact captured input and output. posthog_groups: Optional PostHog groups to associate with the event. + posthog_provider_override: Optional override for the ``$ai_provider`` + reported on the usage event. Useful when this client is pointed at + an OpenAI-compatible endpoint (e.g. DeepSeek, Groq) via a custom + ``base_url``, so cost attribution matches the real provider. + Defaults to ``"openai"`` when omitted. **kwargs: Arguments passed to OpenAI's ``chat.completions.create`` API. Returns: @@ -372,6 +406,10 @@ def create( if posthog_trace_id is None: posthog_trace_id = str(uuid.uuid4()) + posthog_properties = merge_provider_override( + posthog_properties, posthog_provider_override + ) + if kwargs.get("stream", False): return self._create_streaming( posthog_distinct_id, @@ -545,6 +583,7 @@ def create( posthog_properties: Optional[Dict[str, Any]] = None, posthog_privacy_mode: bool = False, posthog_groups: Optional[Dict[str, Any]] = None, + posthog_provider_override: Optional[str] = None, **kwargs: Any, ): """ @@ -556,6 +595,11 @@ def create( 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. + posthog_provider_override: Optional override for the ``$ai_provider`` + reported on the usage event. Useful when this client is pointed at + an OpenAI-compatible endpoint (e.g. DeepSeek, Groq) via a custom + ``base_url``, so cost attribution matches the real provider. + Defaults to ``"openai"`` when omitted. **kwargs: Any additional parameters for the OpenAI Embeddings API. Returns: @@ -565,6 +609,10 @@ def create( if posthog_trace_id is None: posthog_trace_id = str(uuid.uuid4()) + posthog_properties = merge_provider_override( + posthog_properties, posthog_provider_override + ) + start_time = time.time() response = self._original.create(**kwargs) end_time = time.time() @@ -640,6 +688,7 @@ def parse( posthog_properties: Optional[Dict[str, Any]] = None, posthog_privacy_mode: bool = False, posthog_groups: Optional[Dict[str, Any]] = None, + posthog_provider_override: Optional[str] = None, **kwargs: Any, ): """ @@ -651,6 +700,11 @@ def parse( posthog_properties: Additional properties to include with the usage event. posthog_privacy_mode: Whether to redact captured input and output. posthog_groups: Optional PostHog groups to associate with the event. + posthog_provider_override: Optional override for the ``$ai_provider`` + reported on the usage event. Useful when this client is pointed at + an OpenAI-compatible endpoint (e.g. DeepSeek, Groq) via a custom + ``base_url``, so cost attribution matches the real provider. + Defaults to ``"openai"`` when omitted. **kwargs: Arguments passed to OpenAI's beta ``chat.completions.parse`` API. Returns: @@ -663,5 +717,6 @@ def parse( posthog_properties, posthog_privacy_mode, posthog_groups, + posthog_provider_override, **kwargs, ) diff --git a/posthog/ai/openai/openai_async.py b/posthog/ai/openai/openai_async.py index dae17d18..57764415 100644 --- a/posthog/ai/openai/openai_async.py +++ b/posthog/ai/openai/openai_async.py @@ -31,7 +31,10 @@ format_openai_streaming_output, ) from posthog.client import Client as PostHogClient -from posthog.ai.openai.wrapper_utils import _OpenAIWrapperResource +from posthog.ai.openai.wrapper_utils import ( + _OpenAIWrapperResource, + merge_provider_override, +) class AsyncOpenAI(openai.AsyncOpenAI): @@ -80,6 +83,7 @@ async def _parse_and_track( posthog_properties: Optional[Dict[str, Any]], posthog_privacy_mode: bool, posthog_groups: Optional[Dict[str, Any]], + posthog_provider_override: Optional[str] = None, **kwargs: Any, ): return await call_llm_and_track_usage_async( @@ -87,7 +91,7 @@ async def _parse_and_track( wrapper._client._ph_client, "openai", posthog_trace_id, - posthog_properties, + merge_provider_override(posthog_properties, posthog_provider_override), posthog_privacy_mode, posthog_groups, wrapper._client.base_url, @@ -106,6 +110,7 @@ async def create( posthog_properties: Optional[Dict[str, Any]] = None, posthog_privacy_mode: bool = False, posthog_groups: Optional[Dict[str, Any]] = None, + posthog_provider_override: Optional[str] = None, **kwargs: Any, ): """ @@ -117,6 +122,11 @@ async def create( posthog_properties: Additional properties to include with the usage event. posthog_privacy_mode: Whether to redact captured input and output. posthog_groups: Optional PostHog groups to associate with the event. + posthog_provider_override: Optional override for the ``$ai_provider`` + reported on the usage event. Useful when this client is pointed at + an OpenAI-compatible endpoint (e.g. DeepSeek, Groq) via a custom + ``base_url``, so cost attribution matches the real provider. + Defaults to ``"openai"`` when omitted. **kwargs: Arguments passed to OpenAI's async ``responses.create`` API. Returns: @@ -125,6 +135,10 @@ async def create( if posthog_trace_id is None: posthog_trace_id = str(uuid.uuid4()) + posthog_properties = merge_provider_override( + posthog_properties, posthog_provider_override + ) + if kwargs.get("stream", False): return await self._create_streaming( posthog_distinct_id, @@ -313,6 +327,7 @@ async def parse( posthog_properties: Optional[Dict[str, Any]] = None, posthog_privacy_mode: bool = False, posthog_groups: Optional[Dict[str, Any]] = None, + posthog_provider_override: Optional[str] = None, **kwargs: Any, ): """ @@ -324,6 +339,11 @@ async def parse( 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. + posthog_provider_override: Optional override for the ``$ai_provider`` + reported on the usage event. Useful when this client is pointed at + an OpenAI-compatible endpoint (e.g. DeepSeek, Groq) via a custom + ``base_url``, so cost attribution matches the real provider. + Defaults to ``"openai"`` when omitted. **kwargs: Any additional parameters for the OpenAI Responses Parse API. Returns: @@ -336,6 +356,7 @@ async def parse( posthog_properties, posthog_privacy_mode, posthog_groups, + posthog_provider_override, **kwargs, ) @@ -359,6 +380,7 @@ async def parse( posthog_properties: Optional[Dict[str, Any]] = None, posthog_privacy_mode: bool = False, posthog_groups: Optional[Dict[str, Any]] = None, + posthog_provider_override: Optional[str] = None, **kwargs: Any, ): """ @@ -370,6 +392,11 @@ async def parse( posthog_properties: Additional properties to include with the usage event. posthog_privacy_mode: Whether to redact captured input and output. posthog_groups: Optional PostHog groups to associate with the event. + posthog_provider_override: Optional override for the ``$ai_provider`` + reported on the usage event. Useful when this client is pointed at + an OpenAI-compatible endpoint (e.g. DeepSeek, Groq) via a custom + ``base_url``, so cost attribution matches the real provider. + Defaults to ``"openai"`` when omitted. **kwargs: Arguments passed to OpenAI's async ``chat.completions.parse`` API. Returns: @@ -382,6 +409,7 @@ async def parse( posthog_properties, posthog_privacy_mode, posthog_groups, + posthog_provider_override, **kwargs, ) @@ -392,6 +420,7 @@ async def create( posthog_properties: Optional[Dict[str, Any]] = None, posthog_privacy_mode: bool = False, posthog_groups: Optional[Dict[str, Any]] = None, + posthog_provider_override: Optional[str] = None, **kwargs: Any, ): """ @@ -403,6 +432,11 @@ async def create( posthog_properties: Additional properties to include with the usage event. posthog_privacy_mode: Whether to redact captured input and output. posthog_groups: Optional PostHog groups to associate with the event. + posthog_provider_override: Optional override for the ``$ai_provider`` + reported on the usage event. Useful when this client is pointed at + an OpenAI-compatible endpoint (e.g. DeepSeek, Groq) via a custom + ``base_url``, so cost attribution matches the real provider. + Defaults to ``"openai"`` when omitted. **kwargs: Arguments passed to OpenAI's async ``chat.completions.create`` API. Returns: @@ -411,6 +445,10 @@ async def create( if posthog_trace_id is None: posthog_trace_id = str(uuid.uuid4()) + posthog_properties = merge_provider_override( + posthog_properties, posthog_provider_override + ) + # If streaming, handle streaming specifically if kwargs.get("stream", False): return await self._create_streaming( @@ -620,6 +658,7 @@ async def create( posthog_properties: Optional[Dict[str, Any]] = None, posthog_privacy_mode: bool = False, posthog_groups: Optional[Dict[str, Any]] = None, + posthog_provider_override: Optional[str] = None, **kwargs: Any, ): """ @@ -631,6 +670,11 @@ async def create( 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. + posthog_provider_override: Optional override for the ``$ai_provider`` + reported on the usage event. Useful when this client is pointed at + an OpenAI-compatible endpoint (e.g. DeepSeek, Groq) via a custom + ``base_url``, so cost attribution matches the real provider. + Defaults to ``"openai"`` when omitted. **kwargs: Any additional parameters for the OpenAI Embeddings API. Returns: @@ -640,6 +684,10 @@ async def create( if posthog_trace_id is None: posthog_trace_id = str(uuid.uuid4()) + posthog_properties = merge_provider_override( + posthog_properties, posthog_provider_override + ) + start_time = time.time() response = await self._original.create(**kwargs) end_time = time.time() @@ -716,6 +764,7 @@ async def parse( posthog_properties: Optional[Dict[str, Any]] = None, posthog_privacy_mode: bool = False, posthog_groups: Optional[Dict[str, Any]] = None, + posthog_provider_override: Optional[str] = None, **kwargs: Any, ): """ @@ -727,6 +776,11 @@ async def parse( posthog_properties: Additional properties to include with the usage event. posthog_privacy_mode: Whether to redact captured input and output. posthog_groups: Optional PostHog groups to associate with the event. + posthog_provider_override: Optional override for the ``$ai_provider`` + reported on the usage event. Useful when this client is pointed at + an OpenAI-compatible endpoint (e.g. DeepSeek, Groq) via a custom + ``base_url``, so cost attribution matches the real provider. + Defaults to ``"openai"`` when omitted. **kwargs: Arguments passed to OpenAI's async beta ``chat.completions.parse`` API. Returns: @@ -739,5 +793,6 @@ async def parse( posthog_properties, posthog_privacy_mode, posthog_groups, + posthog_provider_override, **kwargs, ) diff --git a/posthog/ai/openai/wrapper_utils.py b/posthog/ai/openai/wrapper_utils.py index 5667668a..5cefab81 100644 --- a/posthog/ai/openai/wrapper_utils.py +++ b/posthog/ai/openai/wrapper_utils.py @@ -1,10 +1,38 @@ import logging +from typing import Any, Dict, Optional log = logging.getLogger("posthog") _fallback_warnings: set[tuple[str, str]] = set() +def merge_provider_override( + posthog_properties: Optional[Dict[str, Any]], + posthog_provider_override: Optional[str], +) -> Optional[Dict[str, Any]]: + """Fold ``posthog_provider_override`` into ``posthog_properties``. + + The wrapper always drives OpenAI's own response-format parsing with the + literal provider name "openai" -- that must stay untouched even when + talking to an OpenAI-compatible endpoint (DeepSeek, Groq, etc. via a + custom ``base_url``), since those responses are still shaped like + OpenAI's. What callers want to override is only the ``$ai_provider`` + value reported on the captured event, so PostHog's cost attribution + matches the real provider. + + Every capture path merges ``posthog_properties`` into the event's + properties *after* the default ``$ai_provider`` value, so setting the + key here is sufficient to win without touching format selection. + + Returns ``posthog_properties`` unchanged when no override is supplied, + so omitting the parameter leaves behavior identical to before it + existed. + """ + if posthog_provider_override is None: + return posthog_properties + return {**(posthog_properties or {}), "$ai_provider": posthog_provider_override} + + def reset_fallback_warnings() -> None: _fallback_warnings.clear() diff --git a/posthog/test/ai/openai/test_openai.py b/posthog/test/ai/openai/test_openai.py index e82bb902..83d4e58f 100644 --- a/posthog/test/ai/openai/test_openai.py +++ b/posthog/test/ai/openai/test_openai.py @@ -2480,3 +2480,296 @@ def test_multimodal_client_skips_media_redaction(mock_client, mock_openai_respon props = call_args["properties"] assert props["$ai_input"][0]["content"][0]["image_url"]["url"] == image + + +# --- posthog_provider_override ----------------------------------------------- +# +# OpenAI-compatible providers (DeepSeek, Groq, Mistral, etc.) are used through +# this same wrapper via a custom `base_url`. Without an override, every event +# reports `$ai_provider: "openai"`, which breaks PostHog's cost attribution for +# those calls. `posthog_provider_override` lets a caller correct just the +# reported provider without changing how the (OpenAI-shaped) response is +# parsed. + + +def test_provider_override_chat_completions(mock_client, mock_openai_response): + with patch( + "openai.resources.chat.completions.Completions.create", + return_value=mock_openai_response, + ) as mock_create: + 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", + posthog_properties={"foo": "bar"}, + posthog_provider_override="deepseek", + ) + + assert response == mock_openai_response + assert mock_create.call_count == 1 + # The override must never reach the underlying OpenAI request. + assert "posthog_provider_override" not in mock_create.call_args.kwargs + + assert mock_client.capture.call_count == 1 + call_args = mock_client.capture.call_args[1] + props = call_args["properties"] + + assert props["$ai_provider"] == "deepseek" + assert props["$ai_model"] == "gpt-4" + # Overriding the provider must not clobber other posthog_properties. + assert props["foo"] == "bar" + + +def test_provider_override_omitted_defaults_to_openai( + mock_client, mock_openai_response +): + """Regression guard: omitting posthog_provider_override must leave + $ai_provider exactly as it was before the parameter existed.""" + with patch( + "openai.resources.chat.completions.Completions.create", + return_value=mock_openai_response, + ): + client = OpenAI(api_key="test-key", posthog_client=mock_client) + client.chat.completions.create( + model="gpt-4", + messages=[{"role": "user", "content": "Hello"}], + posthog_distinct_id="test-id", + ) + + assert mock_client.capture.call_count == 1 + props = mock_client.capture.call_args[1]["properties"] + assert props["$ai_provider"] == "openai" + + +def test_provider_override_streaming_chat_completions(mock_client): + chunks = [ + ChatCompletionChunk( + id="chunk1", + model="gpt-4", + object="chat.completion.chunk", + created=1234567890, + choices=[ + ChoiceChunk( + index=0, + delta=ChoiceDelta(role="assistant", content="Hello"), + finish_reason="stop", + ) + ], + usage=CompletionUsage( + prompt_tokens=10, + completion_tokens=5, + total_tokens=15, + ), + ), + ] + + with patch("openai.resources.chat.completions.Completions.create") as mock_create: + mock_create.return_value = chunks + + client = OpenAI(api_key="test-key", posthog_client=mock_client) + response_generator = client.chat.completions.create( + model="gpt-4", + messages=[{"role": "user", "content": "Hello"}], + stream=True, + posthog_distinct_id="test-id", + posthog_provider_override="groq", + ) + + list(response_generator) + + assert "posthog_provider_override" not in mock_create.call_args.kwargs + assert mock_client.capture.call_count == 1 + props = mock_client.capture.call_args[1]["properties"] + assert props["$ai_provider"] == "groq" + assert props["$ai_model"] == "gpt-4" + + +def test_provider_override_responses_api( + mock_client, mock_openai_response_with_responses_api +): + with patch( + "openai.resources.responses.Responses.create", + return_value=mock_openai_response_with_responses_api, + ) as mock_create: + client = OpenAI(api_key="test-key", posthog_client=mock_client) + response = client.responses.create( + model="gpt-4o-mini", + input="Hello", + posthog_distinct_id="test-id", + posthog_provider_override="xai", + ) + + assert response == mock_openai_response_with_responses_api + assert "posthog_provider_override" not in mock_create.call_args.kwargs + assert mock_client.capture.call_count == 1 + + call_args = mock_client.capture.call_args[1] + props = call_args["properties"] + assert props["$ai_provider"] == "xai" + assert props["$ai_model"] == "gpt-4o-mini" + + +def test_provider_override_embeddings(mock_client, mock_embedding_response): + with patch( + "openai.resources.embeddings.Embeddings.create", + return_value=mock_embedding_response, + ) as mock_create: + client = OpenAI(api_key="test-key", posthog_client=mock_client) + response = client.embeddings.create( + model="text-embedding-3-small", + input="Hello world", + posthog_distinct_id="test-id", + posthog_provider_override="mistral", + ) + + assert response == mock_embedding_response + assert "posthog_provider_override" not in mock_create.call_args.kwargs + assert mock_client.capture.call_count == 1 + + call_args = mock_client.capture.call_args[1] + props = call_args["properties"] + assert props["$ai_provider"] == "mistral" + assert props["$ai_model"] == "text-embedding-3-small" + + +def test_provider_override_chat_completions_parse(mock_client, mock_openai_response): + with patch( + "openai.resources.chat.completions.Completions.parse", + return_value=mock_openai_response, + ) as mock_parse: + client = OpenAI(api_key="test-key", posthog_client=mock_client) + response = client.chat.completions.parse( + model="gpt-4", + messages=[{"role": "user", "content": "Hello"}], + response_format={"type": "json_object"}, + posthog_distinct_id="test-id", + posthog_provider_override="cerebras", + ) + + assert response == mock_openai_response + assert mock_parse.call_count == 1 + assert "posthog_provider_override" not in mock_parse.call_args.kwargs + assert mock_client.capture.call_count == 1 + + call_args = mock_client.capture.call_args[1] + props = call_args["properties"] + assert props["$ai_provider"] == "cerebras" + assert props["$ai_model"] == "gpt-4" + + +@pytest.mark.asyncio +async def test_async_provider_override_chat_completions( + mock_client, mock_openai_response +): + mock_create = AsyncMock(return_value=mock_openai_response) + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", new=mock_create + ): + client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client) + + response = await client.chat.completions.create( + model="gpt-4", + messages=[{"role": "user", "content": "Hello"}], + posthog_distinct_id="test-id", + posthog_provider_override="together", + ) + + assert response == mock_openai_response + mock_create.assert_awaited_once() + assert "posthog_provider_override" not in mock_create.call_args.kwargs + assert mock_client.capture.call_count == 1 + + props = mock_client.capture.call_args[1]["properties"] + assert props["$ai_provider"] == "together" + assert props["$ai_model"] == "gpt-4" + + +@pytest.mark.asyncio +async def test_async_provider_override_omitted_defaults_to_openai( + mock_client, mock_openai_response +): + """Regression guard for the async path: omitting posthog_provider_override + must leave $ai_provider exactly as it was before the parameter existed.""" + mock_create = AsyncMock(return_value=mock_openai_response) + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", new=mock_create + ): + client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client) + + await client.chat.completions.create( + model="gpt-4", + messages=[{"role": "user", "content": "Hello"}], + posthog_distinct_id="test-id", + ) + + assert mock_client.capture.call_count == 1 + props = mock_client.capture.call_args[1]["properties"] + assert props["$ai_provider"] == "openai" + + +@pytest.mark.asyncio +async def test_async_provider_override_streaming_chat_completions( + mock_client, streaming_tool_call_chunks +): + captured_kwargs = {} + + async def mock_create(self, **kwargs): + captured_kwargs["kwargs"] = kwargs + + async def chunk_iterable(): + for chunk in streaming_tool_call_chunks: + yield chunk + + return chunk_iterable() + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", new=mock_create + ): + client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client) + + response_stream = await client.chat.completions.create( + model="gpt-4", + messages=[{"role": "user", "content": "Hello"}], + stream=True, + posthog_distinct_id="test-id", + posthog_provider_override="fireworks", + ) + + chunks = [] + async for chunk in response_stream: + chunks.append(chunk) + + assert "posthog_provider_override" not in captured_kwargs["kwargs"] + assert len(chunks) == len(streaming_tool_call_chunks) + + assert mock_client.capture.call_count == 1 + props = mock_client.capture.call_args[1]["properties"] + assert props["$ai_provider"] == "fireworks" + assert props["$ai_model"] == "gpt-4" + + +@pytest.mark.asyncio +async def test_async_provider_override_embeddings(mock_client, mock_embedding_response): + mock_create = AsyncMock(return_value=mock_embedding_response) + + with patch("openai.resources.embeddings.AsyncEmbeddings.create", new=mock_create): + client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client) + + response = await client.embeddings.create( + model="text-embedding-3-small", + input="Hello world", + posthog_distinct_id="test-id", + posthog_provider_override="perplexity", + ) + + assert response == mock_embedding_response + assert mock_create.await_count == 1 + assert "posthog_provider_override" not in mock_create.call_args.kwargs + assert mock_client.capture.call_count == 1 + + props = mock_client.capture.call_args[1]["properties"] + assert props["$ai_provider"] == "perplexity" + assert props["$ai_model"] == "text-embedding-3-small" From 6bd123d860abb374d047fcbeab6e4d9da14b7314 Mon Sep 17 00:00:00 2001 From: Marco Gancitano Date: Fri, 31 Jul 2026 17:12:23 -0400 Subject: [PATCH 2/2] chore: regenerate the public API snapshot posthog_provider_override is a new public parameter on the OpenAI wrapper, so the recorded surface changed. --- references/public_api_snapshot.txt | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index d83fbb74..396c1027 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -158,6 +158,7 @@ alias posthog.ai.openai.openai.extract_openai_content_from_chunk -> posthog.ai.o alias posthog.ai.openai.openai.extract_openai_tool_calls_from_chunk -> posthog.ai.openai.openai_converter.extract_openai_tool_calls_from_chunk alias posthog.ai.openai.openai.extract_openai_usage_from_chunk -> posthog.ai.openai.openai_converter.extract_openai_usage_from_chunk alias posthog.ai.openai.openai.finalize_ai_content -> posthog.ai.utils.finalize_ai_content +alias posthog.ai.openai.openai.merge_provider_override -> posthog.ai.openai.wrapper_utils.merge_provider_override alias posthog.ai.openai.openai.merge_usage_stats -> posthog.ai.utils.merge_usage_stats alias posthog.ai.openai.openai.setup -> posthog.setup alias posthog.ai.openai.openai.with_privacy_mode -> posthog.ai.utils.with_privacy_mode @@ -174,6 +175,7 @@ alias posthog.ai.openai.openai_async.finalize_ai_content -> posthog.ai.utils.fin alias posthog.ai.openai.openai_async.format_openai_streaming_input -> posthog.ai.openai.openai_converter.format_openai_streaming_input alias posthog.ai.openai.openai_async.format_openai_streaming_output -> posthog.ai.openai.openai_converter.format_openai_streaming_output alias posthog.ai.openai.openai_async.get_model_params -> posthog.ai.utils.get_model_params +alias posthog.ai.openai.openai_async.merge_provider_override -> posthog.ai.openai.wrapper_utils.merge_provider_override alias posthog.ai.openai.openai_async.merge_usage_stats -> posthog.ai.utils.merge_usage_stats alias posthog.ai.openai.openai_async.setup -> posthog.setup alias posthog.ai.openai.openai_async.with_privacy_mode -> posthog.ai.utils.with_privacy_mode @@ -1004,6 +1006,7 @@ function posthog.ai.openai.openai_converter.format_openai_response(response: Any function posthog.ai.openai.openai_converter.format_openai_streaming_content(accumulated_content: str, tool_calls: Optional[List[Dict[str, Any]]] = None) -> List[FormattedContentItem] function posthog.ai.openai.openai_converter.format_openai_streaming_input(kwargs: Dict[str, Any], api_type: str = 'chat') -> Any function posthog.ai.openai.openai_converter.format_openai_streaming_output(accumulated_content: Any, provider_type: str = 'chat', tool_calls: Optional[List[Dict[str, Any]]] = None) -> List[FormattedMessage] +function posthog.ai.openai.wrapper_utils.merge_provider_override(posthog_properties: Optional[Dict[str, Any]], posthog_provider_override: Optional[str]) -> Optional[Dict[str, Any]] function posthog.ai.openai.wrapper_utils.reset_fallback_warnings() -> None function posthog.ai.openai.wrapper_utils.warn_on_fallback(wrapper_name: str, name: str) -> None function posthog.ai.openai_agents.instrument(client: Optional[Client] = None, distinct_id: Optional[Union[str, Callable[[Trace], Optional[str]]]] = None, privacy_mode: bool = False, groups: Optional[Dict[str, Any]] = None, properties: Optional[Dict[str, Any]] = None) -> PostHogTracingProcessor @@ -1200,18 +1203,18 @@ method posthog.ai.langchain.callbacks.CallbackHandler.on_retriever_start(seriali method posthog.ai.langchain.callbacks.CallbackHandler.on_tool_end(output: str, *, run_id: UUID, parent_run_id: Optional[UUID] = None, **kwargs: Any) -> Any method posthog.ai.langchain.callbacks.CallbackHandler.on_tool_error(error: BaseException, *, run_id: UUID, parent_run_id: Optional[UUID] = None, tags: Optional[list[str]] = None, **kwargs: Any) -> Any method posthog.ai.langchain.callbacks.CallbackHandler.on_tool_start(serialized: Optional[Dict[str, Any]], input_str: str, *, run_id: UUID, parent_run_id: Optional[UUID] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Any -method posthog.ai.openai.openai.WrappedBetaCompletions.parse(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) -method posthog.ai.openai.openai.WrappedCompletions.create(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) -method posthog.ai.openai.openai.WrappedCompletions.parse(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) -method posthog.ai.openai.openai.WrappedEmbeddings.create(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) -method posthog.ai.openai.openai.WrappedResponses.create(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) -method posthog.ai.openai.openai.WrappedResponses.parse(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) -method posthog.ai.openai.openai_async.WrappedBetaCompletions.parse(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) -method posthog.ai.openai.openai_async.WrappedCompletions.create(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) -method posthog.ai.openai.openai_async.WrappedCompletions.parse(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) -method posthog.ai.openai.openai_async.WrappedEmbeddings.create(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) -method posthog.ai.openai.openai_async.WrappedResponses.create(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) -method posthog.ai.openai.openai_async.WrappedResponses.parse(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) +method posthog.ai.openai.openai.WrappedBetaCompletions.parse(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, posthog_provider_override: Optional[str] = None, **kwargs: Any) +method posthog.ai.openai.openai.WrappedCompletions.create(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, posthog_provider_override: Optional[str] = None, **kwargs: Any) +method posthog.ai.openai.openai.WrappedCompletions.parse(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, posthog_provider_override: Optional[str] = None, **kwargs: Any) +method posthog.ai.openai.openai.WrappedEmbeddings.create(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, posthog_provider_override: Optional[str] = None, **kwargs: Any) +method posthog.ai.openai.openai.WrappedResponses.create(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, posthog_provider_override: Optional[str] = None, **kwargs: Any) +method posthog.ai.openai.openai.WrappedResponses.parse(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, posthog_provider_override: Optional[str] = None, **kwargs: Any) +method posthog.ai.openai.openai_async.WrappedBetaCompletions.parse(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, posthog_provider_override: Optional[str] = None, **kwargs: Any) +method posthog.ai.openai.openai_async.WrappedCompletions.create(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, posthog_provider_override: Optional[str] = None, **kwargs: Any) +method posthog.ai.openai.openai_async.WrappedCompletions.parse(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, posthog_provider_override: Optional[str] = None, **kwargs: Any) +method posthog.ai.openai.openai_async.WrappedEmbeddings.create(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, posthog_provider_override: Optional[str] = None, **kwargs: Any) +method posthog.ai.openai.openai_async.WrappedResponses.create(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, posthog_provider_override: Optional[str] = None, **kwargs: Any) +method posthog.ai.openai.openai_async.WrappedResponses.parse(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, posthog_provider_override: Optional[str] = None, **kwargs: Any) method posthog.ai.openai_agents.processor.PostHogTracingProcessor.force_flush() -> None method posthog.ai.openai_agents.processor.PostHogTracingProcessor.on_span_end(span: Span[Any]) -> None method posthog.ai.openai_agents.processor.PostHogTracingProcessor.on_span_start(span: Span[Any]) -> None