posthoganalytics 6.7.0__py3-none-any.whl → 7.4.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. posthoganalytics/__init__.py +84 -7
  2. posthoganalytics/ai/anthropic/__init__.py +10 -0
  3. posthoganalytics/ai/anthropic/anthropic.py +95 -65
  4. posthoganalytics/ai/anthropic/anthropic_async.py +95 -65
  5. posthoganalytics/ai/anthropic/anthropic_converter.py +443 -0
  6. posthoganalytics/ai/gemini/__init__.py +15 -1
  7. posthoganalytics/ai/gemini/gemini.py +66 -71
  8. posthoganalytics/ai/gemini/gemini_async.py +423 -0
  9. posthoganalytics/ai/gemini/gemini_converter.py +652 -0
  10. posthoganalytics/ai/langchain/callbacks.py +58 -13
  11. posthoganalytics/ai/openai/__init__.py +16 -1
  12. posthoganalytics/ai/openai/openai.py +140 -149
  13. posthoganalytics/ai/openai/openai_async.py +127 -82
  14. posthoganalytics/ai/openai/openai_converter.py +741 -0
  15. posthoganalytics/ai/sanitization.py +248 -0
  16. posthoganalytics/ai/types.py +125 -0
  17. posthoganalytics/ai/utils.py +339 -356
  18. posthoganalytics/client.py +345 -97
  19. posthoganalytics/contexts.py +81 -0
  20. posthoganalytics/exception_utils.py +250 -2
  21. posthoganalytics/feature_flags.py +26 -10
  22. posthoganalytics/flag_definition_cache.py +127 -0
  23. posthoganalytics/integrations/django.py +157 -19
  24. posthoganalytics/request.py +203 -23
  25. posthoganalytics/test/test_client.py +250 -22
  26. posthoganalytics/test/test_exception_capture.py +418 -0
  27. posthoganalytics/test/test_feature_flag_result.py +441 -2
  28. posthoganalytics/test/test_feature_flags.py +308 -104
  29. posthoganalytics/test/test_flag_definition_cache.py +612 -0
  30. posthoganalytics/test/test_module.py +0 -8
  31. posthoganalytics/test/test_request.py +536 -0
  32. posthoganalytics/test/test_utils.py +4 -1
  33. posthoganalytics/types.py +40 -0
  34. posthoganalytics/version.py +1 -1
  35. {posthoganalytics-6.7.0.dist-info → posthoganalytics-7.4.3.dist-info}/METADATA +12 -12
  36. posthoganalytics-7.4.3.dist-info/RECORD +57 -0
  37. posthoganalytics-6.7.0.dist-info/RECORD +0 -49
  38. {posthoganalytics-6.7.0.dist-info → posthoganalytics-7.4.3.dist-info}/WHEEL +0 -0
  39. {posthoganalytics-6.7.0.dist-info → posthoganalytics-7.4.3.dist-info}/licenses/LICENSE +0 -0
  40. {posthoganalytics-6.7.0.dist-info → posthoganalytics-7.4.3.dist-info}/top_level.txt +0 -0
@@ -1,8 +1,8 @@
1
1
  try:
2
- import langchain # noqa: F401
2
+ import langchain_core # noqa: F401
3
3
  except ImportError:
4
4
  raise ModuleNotFoundError(
5
- "Please install LangChain to use this feature: 'pip install langchain'"
5
+ "Please install LangChain to use this feature: 'pip install langchain-core'"
6
6
  )
7
7
 
8
8
  import json
@@ -20,8 +20,14 @@ from typing import (
20
20
  )
21
21
  from uuid import UUID
22
22
 
23
- from langchain.callbacks.base import BaseCallbackHandler
24
- from langchain.schema.agent import AgentAction, AgentFinish
23
+ try:
24
+ # LangChain 1.0+ and modern 0.x with langchain-core
25
+ from langchain_core.callbacks.base import BaseCallbackHandler
26
+ from langchain_core.agents import AgentAction, AgentFinish
27
+ except (ImportError, ModuleNotFoundError):
28
+ # Fallback for older LangChain versions
29
+ from langchain.callbacks.base import BaseCallbackHandler
30
+ from langchain.schema.agent import AgentAction, AgentFinish
25
31
  from langchain_core.documents import Document
26
32
  from langchain_core.messages import (
27
33
  AIMessage,
@@ -37,6 +43,7 @@ from pydantic import BaseModel
37
43
 
38
44
  from posthoganalytics import setup
39
45
  from posthoganalytics.ai.utils import get_model_params, with_privacy_mode
46
+ from posthoganalytics.ai.sanitization import sanitize_langchain
40
47
  from posthoganalytics.client import Client
41
48
 
42
49
  log = logging.getLogger("posthog")
@@ -72,6 +79,8 @@ class GenerationMetadata(SpanMetadata):
72
79
  """Base URL of the provider's API used in the run."""
73
80
  tools: Optional[List[Dict[str, Any]]] = None
74
81
  """Tools provided to the model."""
82
+ posthog_properties: Optional[Dict[str, Any]] = None
83
+ """PostHog properties of the run."""
75
84
 
76
85
 
77
86
  RunMetadata = Union[SpanMetadata, GenerationMetadata]
@@ -413,6 +422,8 @@ class CallbackHandler(BaseCallbackHandler):
413
422
  generation.model = model
414
423
  if provider := metadata.get("ls_provider"):
415
424
  generation.provider = provider
425
+
426
+ generation.posthog_properties = metadata.get("posthog_properties")
416
427
  try:
417
428
  base_url = serialized["kwargs"]["openai_api_base"]
418
429
  if base_url is not None:
@@ -480,11 +491,12 @@ class CallbackHandler(BaseCallbackHandler):
480
491
  event_properties = {
481
492
  "$ai_trace_id": trace_id,
482
493
  "$ai_input_state": with_privacy_mode(
483
- self._ph_client, self._privacy_mode, run.input
494
+ self._ph_client, self._privacy_mode, sanitize_langchain(run.input)
484
495
  ),
485
496
  "$ai_latency": run.latency,
486
497
  "$ai_span_name": run.name,
487
498
  "$ai_span_id": run_id,
499
+ "$ai_framework": "langchain",
488
500
  }
489
501
  if parent_run_id is not None:
490
502
  event_properties["$ai_parent_id"] = parent_run_id
@@ -550,13 +562,17 @@ class CallbackHandler(BaseCallbackHandler):
550
562
  "$ai_model": run.model,
551
563
  "$ai_model_parameters": run.model_params,
552
564
  "$ai_input": with_privacy_mode(
553
- self._ph_client, self._privacy_mode, run.input
565
+ self._ph_client, self._privacy_mode, sanitize_langchain(run.input)
554
566
  ),
555
567
  "$ai_http_status": 200,
556
568
  "$ai_latency": run.latency,
557
569
  "$ai_base_url": run.base_url,
570
+ "$ai_framework": "langchain",
558
571
  }
559
572
 
573
+ if isinstance(run.posthog_properties, dict):
574
+ event_properties.update(run.posthog_properties)
575
+
560
576
  if run.tools:
561
577
  event_properties["$ai_tools"] = run.tools
562
578
 
@@ -566,7 +582,7 @@ class CallbackHandler(BaseCallbackHandler):
566
582
  event_properties["$ai_is_error"] = True
567
583
  else:
568
584
  # Add usage
569
- usage = _parse_usage(output)
585
+ usage = _parse_usage(output, run.provider, run.model)
570
586
  event_properties["$ai_input_tokens"] = usage.input_tokens
571
587
  event_properties["$ai_output_tokens"] = usage.output_tokens
572
588
  event_properties["$ai_cache_creation_input_tokens"] = (
@@ -687,6 +703,8 @@ class ModelUsage:
687
703
 
688
704
  def _parse_usage_model(
689
705
  usage: Union[BaseModel, dict],
706
+ provider: Optional[str] = None,
707
+ model: Optional[str] = None,
690
708
  ) -> ModelUsage:
691
709
  if isinstance(usage, BaseModel):
692
710
  usage = usage.__dict__
@@ -749,15 +767,38 @@ def _parse_usage_model(
749
767
  "cache_read": "cache_read_tokens",
750
768
  "reasoning": "reasoning_tokens",
751
769
  }
752
- return ModelUsage(
770
+ normalized_usage = ModelUsage(
753
771
  **{
754
772
  dataclass_key: parsed_usage.get(mapped_key) or 0
755
773
  for mapped_key, dataclass_key in field_mapping.items()
756
774
  },
757
775
  )
776
+ # For Anthropic providers, LangChain reports input_tokens as the sum of all input tokens.
777
+ # Our cost calculation expects them to be separate for Anthropic, so we subtract cache tokens.
778
+ # Both cache_read and cache_write tokens should be subtracted since Anthropic's raw API
779
+ # reports input_tokens as tokens NOT read from or used to create a cache.
780
+ # For other providers (OpenAI, etc.), input_tokens already excludes cache tokens as expected.
781
+ # Match logic consistent with plugin-server: exact match on provider OR substring match on model
782
+ is_anthropic = False
783
+ if provider and provider.lower() == "anthropic":
784
+ is_anthropic = True
785
+ elif model and "anthropic" in model.lower():
786
+ is_anthropic = True
787
+
788
+ if is_anthropic and normalized_usage.input_tokens:
789
+ cache_tokens = (normalized_usage.cache_read_tokens or 0) + (
790
+ normalized_usage.cache_write_tokens or 0
791
+ )
792
+ if cache_tokens > 0:
793
+ normalized_usage.input_tokens = max(
794
+ normalized_usage.input_tokens - cache_tokens, 0
795
+ )
796
+ return normalized_usage
758
797
 
759
798
 
760
- def _parse_usage(response: LLMResult) -> ModelUsage:
799
+ def _parse_usage(
800
+ response: LLMResult, provider: Optional[str] = None, model: Optional[str] = None
801
+ ) -> ModelUsage:
761
802
  # langchain-anthropic uses the usage field
762
803
  llm_usage_keys = ["token_usage", "usage"]
763
804
  llm_usage: ModelUsage = ModelUsage(
@@ -771,13 +812,15 @@ def _parse_usage(response: LLMResult) -> ModelUsage:
771
812
  if response.llm_output is not None:
772
813
  for key in llm_usage_keys:
773
814
  if response.llm_output.get(key):
774
- llm_usage = _parse_usage_model(response.llm_output[key])
815
+ llm_usage = _parse_usage_model(
816
+ response.llm_output[key], provider, model
817
+ )
775
818
  break
776
819
 
777
820
  if hasattr(response, "generations"):
778
821
  for generation in response.generations:
779
822
  if "usage" in generation:
780
- llm_usage = _parse_usage_model(generation["usage"])
823
+ llm_usage = _parse_usage_model(generation["usage"], provider, model)
781
824
  break
782
825
 
783
826
  for generation_chunk in generation:
@@ -785,7 +828,9 @@ def _parse_usage(response: LLMResult) -> ModelUsage:
785
828
  "usage_metadata" in generation_chunk.generation_info
786
829
  ):
787
830
  llm_usage = _parse_usage_model(
788
- generation_chunk.generation_info["usage_metadata"]
831
+ generation_chunk.generation_info["usage_metadata"],
832
+ provider,
833
+ model,
789
834
  )
790
835
  break
791
836
 
@@ -812,7 +857,7 @@ def _parse_usage(response: LLMResult) -> ModelUsage:
812
857
  bedrock_anthropic_usage or bedrock_titan_usage or ollama_usage
813
858
  )
814
859
  if chunk_usage:
815
- llm_usage = _parse_usage_model(chunk_usage)
860
+ llm_usage = _parse_usage_model(chunk_usage, provider, model)
816
861
  break
817
862
 
818
863
  return llm_usage
@@ -1,5 +1,20 @@
1
1
  from .openai import OpenAI
2
2
  from .openai_async import AsyncOpenAI
3
3
  from .openai_providers import AsyncAzureOpenAI, AzureOpenAI
4
+ from .openai_converter import (
5
+ format_openai_response,
6
+ format_openai_input,
7
+ extract_openai_tools,
8
+ format_openai_streaming_content,
9
+ )
4
10
 
5
- __all__ = ["OpenAI", "AsyncOpenAI", "AzureOpenAI", "AsyncAzureOpenAI"]
11
+ __all__ = [
12
+ "OpenAI",
13
+ "AsyncOpenAI",
14
+ "AzureOpenAI",
15
+ "AsyncAzureOpenAI",
16
+ "format_openai_response",
17
+ "format_openai_input",
18
+ "extract_openai_tools",
19
+ "format_openai_streaming_content",
20
+ ]
@@ -2,6 +2,8 @@ import time
2
2
  import uuid
3
3
  from typing import Any, Dict, List, Optional
4
4
 
5
+ from posthoganalytics.ai.types import TokenUsage
6
+
5
7
  try:
6
8
  import openai
7
9
  except ImportError:
@@ -12,9 +14,16 @@ except ImportError:
12
14
  from posthoganalytics.ai.utils import (
13
15
  call_llm_and_track_usage,
14
16
  extract_available_tool_calls,
15
- get_model_params,
17
+ merge_usage_stats,
16
18
  with_privacy_mode,
17
19
  )
20
+ from posthoganalytics.ai.openai.openai_converter import (
21
+ extract_openai_usage_from_chunk,
22
+ extract_openai_content_from_chunk,
23
+ extract_openai_tool_calls_from_chunk,
24
+ accumulate_openai_tool_calls,
25
+ )
26
+ from posthoganalytics.ai.sanitization import sanitize_openai, sanitize_openai_response
18
27
  from posthoganalytics.client import Client as PostHogClient
19
28
  from posthoganalytics import setup
20
29
 
@@ -33,6 +42,7 @@ class OpenAI(openai.OpenAI):
33
42
  posthog_client: If provided, events will be captured via this client instead of the global `posthog`.
34
43
  **openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
35
44
  """
45
+
36
46
  super().__init__(**kwargs)
37
47
  self._ph_client = posthog_client or setup()
38
48
 
@@ -112,45 +122,36 @@ class WrappedResponses:
112
122
  **kwargs: Any,
113
123
  ):
114
124
  start_time = time.time()
115
- usage_stats: Dict[str, int] = {}
125
+ usage_stats: TokenUsage = TokenUsage()
116
126
  final_content = []
127
+ model_from_response: Optional[str] = None
117
128
  response = self._original.create(**kwargs)
118
129
 
119
130
  def generator():
120
131
  nonlocal usage_stats
121
132
  nonlocal final_content # noqa: F824
133
+ nonlocal model_from_response
122
134
 
123
135
  try:
124
136
  for chunk in response:
125
- if hasattr(chunk, "type") and chunk.type == "response.completed":
126
- res = chunk.response
127
- if res.output and len(res.output) > 0:
128
- final_content.append(res.output[0])
129
-
130
- if hasattr(chunk, "usage") and chunk.usage:
131
- usage_stats = {
132
- k: getattr(chunk.usage, k, 0)
133
- for k in [
134
- "input_tokens",
135
- "output_tokens",
136
- "total_tokens",
137
- ]
138
- }
139
-
140
- # Add support for cached tokens
141
- if hasattr(chunk.usage, "output_tokens_details") and hasattr(
142
- chunk.usage.output_tokens_details, "reasoning_tokens"
137
+ # Extract model from response object in chunk (for stored prompts)
138
+ if hasattr(chunk, "response") and chunk.response:
139
+ if model_from_response is None and hasattr(
140
+ chunk.response, "model"
143
141
  ):
144
- usage_stats["reasoning_tokens"] = (
145
- chunk.usage.output_tokens_details.reasoning_tokens
146
- )
142
+ model_from_response = chunk.response.model
147
143
 
148
- if hasattr(chunk.usage, "input_tokens_details") and hasattr(
149
- chunk.usage.input_tokens_details, "cached_tokens"
150
- ):
151
- usage_stats["cache_read_input_tokens"] = (
152
- chunk.usage.input_tokens_details.cached_tokens
153
- )
144
+ # Extract usage stats from chunk
145
+ chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
146
+
147
+ if chunk_usage:
148
+ merge_usage_stats(usage_stats, chunk_usage)
149
+
150
+ # Extract content from chunk
151
+ content = extract_openai_content_from_chunk(chunk, "responses")
152
+
153
+ if content is not None:
154
+ final_content.append(content)
154
155
 
155
156
  yield chunk
156
157
 
@@ -168,7 +169,8 @@ class WrappedResponses:
168
169
  usage_stats,
169
170
  latency,
170
171
  output,
171
- extract_available_tool_calls("openai", kwargs),
172
+ None, # Responses API doesn't have tools
173
+ model_from_response,
172
174
  )
173
175
 
174
176
  return generator()
@@ -181,52 +183,44 @@ class WrappedResponses:
181
183
  posthog_privacy_mode: bool,
182
184
  posthog_groups: Optional[Dict[str, Any]],
183
185
  kwargs: Dict[str, Any],
184
- usage_stats: Dict[str, int],
186
+ usage_stats: TokenUsage,
185
187
  latency: float,
186
188
  output: Any,
187
189
  available_tool_calls: Optional[List[Dict[str, Any]]] = None,
190
+ model_from_response: Optional[str] = None,
188
191
  ):
189
- if posthog_trace_id is None:
190
- posthog_trace_id = str(uuid.uuid4())
191
-
192
- event_properties = {
193
- "$ai_provider": "openai",
194
- "$ai_model": kwargs.get("model"),
195
- "$ai_model_parameters": get_model_params(kwargs),
196
- "$ai_input": with_privacy_mode(
197
- self._client._ph_client, posthog_privacy_mode, kwargs.get("input")
198
- ),
199
- "$ai_output_choices": with_privacy_mode(
200
- self._client._ph_client,
201
- posthog_privacy_mode,
202
- output,
203
- ),
204
- "$ai_http_status": 200,
205
- "$ai_input_tokens": usage_stats.get("input_tokens", 0),
206
- "$ai_output_tokens": usage_stats.get("output_tokens", 0),
207
- "$ai_cache_read_input_tokens": usage_stats.get(
208
- "cache_read_input_tokens", 0
209
- ),
210
- "$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
211
- "$ai_latency": latency,
212
- "$ai_trace_id": posthog_trace_id,
213
- "$ai_base_url": str(self._client.base_url),
214
- **(posthog_properties or {}),
215
- }
216
-
217
- if available_tool_calls:
218
- event_properties["$ai_tools"] = available_tool_calls
219
-
220
- if posthog_distinct_id is None:
221
- event_properties["$process_person_profile"] = False
192
+ from posthoganalytics.ai.types import StreamingEventData
193
+ from posthoganalytics.ai.openai.openai_converter import (
194
+ format_openai_streaming_input,
195
+ format_openai_streaming_output,
196
+ )
197
+ from posthoganalytics.ai.utils import capture_streaming_event
198
+
199
+ # Prepare standardized event data
200
+ formatted_input = format_openai_streaming_input(kwargs, "responses")
201
+ sanitized_input = sanitize_openai_response(formatted_input)
202
+
203
+ # Use model from kwargs, fallback to model from response
204
+ model = kwargs.get("model") or model_from_response or "unknown"
205
+
206
+ event_data = StreamingEventData(
207
+ provider="openai",
208
+ model=model,
209
+ base_url=str(self._client.base_url),
210
+ kwargs=kwargs,
211
+ formatted_input=sanitized_input,
212
+ formatted_output=format_openai_streaming_output(output, "responses"),
213
+ usage_stats=usage_stats,
214
+ latency=latency,
215
+ distinct_id=posthog_distinct_id,
216
+ trace_id=posthog_trace_id,
217
+ properties=posthog_properties,
218
+ privacy_mode=posthog_privacy_mode,
219
+ groups=posthog_groups,
220
+ )
222
221
 
223
- if hasattr(self._client._ph_client, "capture"):
224
- self._client._ph_client.capture(
225
- distinct_id=posthog_distinct_id or posthog_trace_id,
226
- event="$ai_generation",
227
- properties=event_properties,
228
- groups=posthog_groups,
229
- )
222
+ # Use the common capture function
223
+ capture_streaming_event(self._client._ph_client, event_data)
230
224
 
231
225
  def parse(
232
226
  self,
@@ -337,8 +331,10 @@ class WrappedCompletions:
337
331
  **kwargs: Any,
338
332
  ):
339
333
  start_time = time.time()
340
- usage_stats: Dict[str, int] = {}
334
+ usage_stats: TokenUsage = TokenUsage()
341
335
  accumulated_content = []
336
+ accumulated_tool_calls: Dict[int, Dict[str, Any]] = {}
337
+ model_from_response: Optional[str] = None
342
338
  if "stream_options" not in kwargs:
343
339
  kwargs["stream_options"] = {}
344
340
  kwargs["stream_options"]["include_usage"] = True
@@ -347,50 +343,47 @@ class WrappedCompletions:
347
343
  def generator():
348
344
  nonlocal usage_stats
349
345
  nonlocal accumulated_content # noqa: F824
346
+ nonlocal accumulated_tool_calls
347
+ nonlocal model_from_response
350
348
 
351
349
  try:
352
350
  for chunk in response:
353
- if hasattr(chunk, "usage") and chunk.usage:
354
- usage_stats = {
355
- k: getattr(chunk.usage, k, 0)
356
- for k in [
357
- "prompt_tokens",
358
- "completion_tokens",
359
- "total_tokens",
360
- ]
361
- }
362
-
363
- # Add support for cached tokens
364
- if hasattr(chunk.usage, "prompt_tokens_details") and hasattr(
365
- chunk.usage.prompt_tokens_details, "cached_tokens"
366
- ):
367
- usage_stats["cache_read_input_tokens"] = (
368
- chunk.usage.prompt_tokens_details.cached_tokens
369
- )
351
+ # Extract model from chunk (Chat Completions chunks have model field)
352
+ if model_from_response is None and hasattr(chunk, "model"):
353
+ model_from_response = chunk.model
370
354
 
371
- if hasattr(chunk.usage, "output_tokens_details") and hasattr(
372
- chunk.usage.output_tokens_details, "reasoning_tokens"
373
- ):
374
- usage_stats["reasoning_tokens"] = (
375
- chunk.usage.output_tokens_details.reasoning_tokens
376
- )
377
-
378
- if (
379
- hasattr(chunk, "choices")
380
- and chunk.choices
381
- and len(chunk.choices) > 0
382
- ):
383
- if chunk.choices[0].delta and chunk.choices[0].delta.content:
384
- content = chunk.choices[0].delta.content
385
- if content:
386
- accumulated_content.append(content)
355
+ # Extract usage stats from chunk
356
+ chunk_usage = extract_openai_usage_from_chunk(chunk, "chat")
357
+
358
+ if chunk_usage:
359
+ merge_usage_stats(usage_stats, chunk_usage)
360
+
361
+ # Extract content from chunk
362
+ content = extract_openai_content_from_chunk(chunk, "chat")
363
+
364
+ if content is not None:
365
+ accumulated_content.append(content)
366
+
367
+ # Extract and accumulate tool calls from chunk
368
+ chunk_tool_calls = extract_openai_tool_calls_from_chunk(chunk)
369
+ if chunk_tool_calls:
370
+ accumulate_openai_tool_calls(
371
+ accumulated_tool_calls, chunk_tool_calls
372
+ )
387
373
 
388
374
  yield chunk
389
375
 
390
376
  finally:
391
377
  end_time = time.time()
392
378
  latency = end_time - start_time
393
- output = "".join(accumulated_content)
379
+
380
+ # Convert accumulated tool calls dict to list
381
+ tool_calls_list = (
382
+ list(accumulated_tool_calls.values())
383
+ if accumulated_tool_calls
384
+ else None
385
+ )
386
+
394
387
  self._capture_streaming_event(
395
388
  posthog_distinct_id,
396
389
  posthog_trace_id,
@@ -400,8 +393,10 @@ class WrappedCompletions:
400
393
  kwargs,
401
394
  usage_stats,
402
395
  latency,
403
- output,
396
+ accumulated_content,
397
+ tool_calls_list,
404
398
  extract_available_tool_calls("openai", kwargs),
399
+ model_from_response,
405
400
  )
406
401
 
407
402
  return generator()
@@ -414,52 +409,45 @@ class WrappedCompletions:
414
409
  posthog_privacy_mode: bool,
415
410
  posthog_groups: Optional[Dict[str, Any]],
416
411
  kwargs: Dict[str, Any],
417
- usage_stats: Dict[str, int],
412
+ usage_stats: TokenUsage,
418
413
  latency: float,
419
414
  output: Any,
415
+ tool_calls: Optional[List[Dict[str, Any]]] = None,
420
416
  available_tool_calls: Optional[List[Dict[str, Any]]] = None,
417
+ model_from_response: Optional[str] = None,
421
418
  ):
422
- if posthog_trace_id is None:
423
- posthog_trace_id = str(uuid.uuid4())
424
-
425
- event_properties = {
426
- "$ai_provider": "openai",
427
- "$ai_model": kwargs.get("model"),
428
- "$ai_model_parameters": get_model_params(kwargs),
429
- "$ai_input": with_privacy_mode(
430
- self._client._ph_client, posthog_privacy_mode, kwargs.get("messages")
431
- ),
432
- "$ai_output_choices": with_privacy_mode(
433
- self._client._ph_client,
434
- posthog_privacy_mode,
435
- [{"content": output, "role": "assistant"}],
436
- ),
437
- "$ai_http_status": 200,
438
- "$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
439
- "$ai_output_tokens": usage_stats.get("completion_tokens", 0),
440
- "$ai_cache_read_input_tokens": usage_stats.get(
441
- "cache_read_input_tokens", 0
442
- ),
443
- "$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
444
- "$ai_latency": latency,
445
- "$ai_trace_id": posthog_trace_id,
446
- "$ai_base_url": str(self._client.base_url),
447
- **(posthog_properties or {}),
448
- }
449
-
450
- if available_tool_calls:
451
- event_properties["$ai_tools"] = available_tool_calls
452
-
453
- if posthog_distinct_id is None:
454
- event_properties["$process_person_profile"] = False
419
+ from posthoganalytics.ai.types import StreamingEventData
420
+ from posthoganalytics.ai.openai.openai_converter import (
421
+ format_openai_streaming_input,
422
+ format_openai_streaming_output,
423
+ )
424
+ from posthoganalytics.ai.utils import capture_streaming_event
425
+
426
+ # Prepare standardized event data
427
+ formatted_input = format_openai_streaming_input(kwargs, "chat")
428
+ sanitized_input = sanitize_openai(formatted_input)
429
+
430
+ # Use model from kwargs, fallback to model from response
431
+ model = kwargs.get("model") or model_from_response or "unknown"
432
+
433
+ event_data = StreamingEventData(
434
+ provider="openai",
435
+ model=model,
436
+ base_url=str(self._client.base_url),
437
+ kwargs=kwargs,
438
+ formatted_input=sanitized_input,
439
+ formatted_output=format_openai_streaming_output(output, "chat", tool_calls),
440
+ usage_stats=usage_stats,
441
+ latency=latency,
442
+ distinct_id=posthog_distinct_id,
443
+ trace_id=posthog_trace_id,
444
+ properties=posthog_properties,
445
+ privacy_mode=posthog_privacy_mode,
446
+ groups=posthog_groups,
447
+ )
455
448
 
456
- if hasattr(self._client._ph_client, "capture"):
457
- self._client._ph_client.capture(
458
- distinct_id=posthog_distinct_id or posthog_trace_id,
459
- event="$ai_generation",
460
- properties=event_properties,
461
- groups=posthog_groups,
462
- )
449
+ # Use the common capture function
450
+ capture_streaming_event(self._client._ph_client, event_data)
463
451
 
464
452
 
465
453
  class WrappedEmbeddings:
@@ -496,6 +484,7 @@ class WrappedEmbeddings:
496
484
  Returns:
497
485
  The response from OpenAI's embeddings.create call.
498
486
  """
487
+
499
488
  if posthog_trace_id is None:
500
489
  posthog_trace_id = str(uuid.uuid4())
501
490
 
@@ -518,7 +507,9 @@ class WrappedEmbeddings:
518
507
  "$ai_provider": "openai",
519
508
  "$ai_model": kwargs.get("model"),
520
509
  "$ai_input": with_privacy_mode(
521
- self._client._ph_client, posthog_privacy_mode, kwargs.get("input")
510
+ self._client._ph_client,
511
+ posthog_privacy_mode,
512
+ sanitize_openai_response(kwargs.get("input")),
522
513
  ),
523
514
  "$ai_http_status": 200,
524
515
  "$ai_input_tokens": usage_stats.get("prompt_tokens", 0),