posthoganalytics 7.0.1__py3-none-any.whl → 7.4.1__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 (28) hide show
  1. posthoganalytics/__init__.py +10 -0
  2. posthoganalytics/ai/gemini/__init__.py +3 -0
  3. posthoganalytics/ai/gemini/gemini.py +1 -1
  4. posthoganalytics/ai/gemini/gemini_async.py +423 -0
  5. posthoganalytics/ai/gemini/gemini_converter.py +87 -21
  6. posthoganalytics/ai/openai/openai.py +27 -2
  7. posthoganalytics/ai/openai/openai_async.py +27 -2
  8. posthoganalytics/ai/openai/openai_converter.py +6 -0
  9. posthoganalytics/ai/sanitization.py +27 -5
  10. posthoganalytics/ai/utils.py +2 -2
  11. posthoganalytics/client.py +224 -58
  12. posthoganalytics/exception_utils.py +49 -4
  13. posthoganalytics/flag_definition_cache.py +127 -0
  14. posthoganalytics/request.py +203 -23
  15. posthoganalytics/test/test_client.py +207 -22
  16. posthoganalytics/test/test_exception_capture.py +45 -1
  17. posthoganalytics/test/test_feature_flag_result.py +441 -2
  18. posthoganalytics/test/test_feature_flags.py +166 -73
  19. posthoganalytics/test/test_flag_definition_cache.py +612 -0
  20. posthoganalytics/test/test_request.py +536 -0
  21. posthoganalytics/test/test_utils.py +4 -1
  22. posthoganalytics/types.py +40 -0
  23. posthoganalytics/version.py +1 -1
  24. {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/METADATA +2 -1
  25. {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/RECORD +28 -25
  26. {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/WHEEL +0 -0
  27. {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/licenses/LICENSE +0 -0
  28. {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/top_level.txt +0 -0
@@ -124,14 +124,23 @@ class WrappedResponses:
124
124
  start_time = time.time()
125
125
  usage_stats: TokenUsage = TokenUsage()
126
126
  final_content = []
127
+ model_from_response: Optional[str] = None
127
128
  response = self._original.create(**kwargs)
128
129
 
129
130
  def generator():
130
131
  nonlocal usage_stats
131
132
  nonlocal final_content # noqa: F824
133
+ nonlocal model_from_response
132
134
 
133
135
  try:
134
136
  for chunk in response:
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"
141
+ ):
142
+ model_from_response = chunk.response.model
143
+
135
144
  # Extract usage stats from chunk
136
145
  chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
137
146
 
@@ -161,6 +170,7 @@ class WrappedResponses:
161
170
  latency,
162
171
  output,
163
172
  None, # Responses API doesn't have tools
173
+ model_from_response,
164
174
  )
165
175
 
166
176
  return generator()
@@ -177,6 +187,7 @@ class WrappedResponses:
177
187
  latency: float,
178
188
  output: Any,
179
189
  available_tool_calls: Optional[List[Dict[str, Any]]] = None,
190
+ model_from_response: Optional[str] = None,
180
191
  ):
181
192
  from posthoganalytics.ai.types import StreamingEventData
182
193
  from posthoganalytics.ai.openai.openai_converter import (
@@ -189,9 +200,12 @@ class WrappedResponses:
189
200
  formatted_input = format_openai_streaming_input(kwargs, "responses")
190
201
  sanitized_input = sanitize_openai_response(formatted_input)
191
202
 
203
+ # Use model from kwargs, fallback to model from response
204
+ model = kwargs.get("model") or model_from_response or "unknown"
205
+
192
206
  event_data = StreamingEventData(
193
207
  provider="openai",
194
- model=kwargs.get("model", "unknown"),
208
+ model=model,
195
209
  base_url=str(self._client.base_url),
196
210
  kwargs=kwargs,
197
211
  formatted_input=sanitized_input,
@@ -320,6 +334,7 @@ class WrappedCompletions:
320
334
  usage_stats: TokenUsage = TokenUsage()
321
335
  accumulated_content = []
322
336
  accumulated_tool_calls: Dict[int, Dict[str, Any]] = {}
337
+ model_from_response: Optional[str] = None
323
338
  if "stream_options" not in kwargs:
324
339
  kwargs["stream_options"] = {}
325
340
  kwargs["stream_options"]["include_usage"] = True
@@ -329,9 +344,14 @@ class WrappedCompletions:
329
344
  nonlocal usage_stats
330
345
  nonlocal accumulated_content # noqa: F824
331
346
  nonlocal accumulated_tool_calls
347
+ nonlocal model_from_response
332
348
 
333
349
  try:
334
350
  for chunk in response:
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
354
+
335
355
  # Extract usage stats from chunk
336
356
  chunk_usage = extract_openai_usage_from_chunk(chunk, "chat")
337
357
 
@@ -376,6 +396,7 @@ class WrappedCompletions:
376
396
  accumulated_content,
377
397
  tool_calls_list,
378
398
  extract_available_tool_calls("openai", kwargs),
399
+ model_from_response,
379
400
  )
380
401
 
381
402
  return generator()
@@ -393,6 +414,7 @@ class WrappedCompletions:
393
414
  output: Any,
394
415
  tool_calls: Optional[List[Dict[str, Any]]] = None,
395
416
  available_tool_calls: Optional[List[Dict[str, Any]]] = None,
417
+ model_from_response: Optional[str] = None,
396
418
  ):
397
419
  from posthoganalytics.ai.types import StreamingEventData
398
420
  from posthoganalytics.ai.openai.openai_converter import (
@@ -405,9 +427,12 @@ class WrappedCompletions:
405
427
  formatted_input = format_openai_streaming_input(kwargs, "chat")
406
428
  sanitized_input = sanitize_openai(formatted_input)
407
429
 
430
+ # Use model from kwargs, fallback to model from response
431
+ model = kwargs.get("model") or model_from_response or "unknown"
432
+
408
433
  event_data = StreamingEventData(
409
434
  provider="openai",
410
- model=kwargs.get("model", "unknown"),
435
+ model=model,
411
436
  base_url=str(self._client.base_url),
412
437
  kwargs=kwargs,
413
438
  formatted_input=sanitized_input,
@@ -128,14 +128,23 @@ class WrappedResponses:
128
128
  start_time = time.time()
129
129
  usage_stats: TokenUsage = TokenUsage()
130
130
  final_content = []
131
+ model_from_response: Optional[str] = None
131
132
  response = await self._original.create(**kwargs)
132
133
 
133
134
  async def async_generator():
134
135
  nonlocal usage_stats
135
136
  nonlocal final_content # noqa: F824
137
+ nonlocal model_from_response
136
138
 
137
139
  try:
138
140
  async for chunk in response:
141
+ # Extract model from response object in chunk (for stored prompts)
142
+ if hasattr(chunk, "response") and chunk.response:
143
+ if model_from_response is None and hasattr(
144
+ chunk.response, "model"
145
+ ):
146
+ model_from_response = chunk.response.model
147
+
139
148
  # Extract usage stats from chunk
140
149
  chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
141
150
 
@@ -166,6 +175,7 @@ class WrappedResponses:
166
175
  latency,
167
176
  output,
168
177
  extract_available_tool_calls("openai", kwargs),
178
+ model_from_response,
169
179
  )
170
180
 
171
181
  return async_generator()
@@ -182,13 +192,17 @@ class WrappedResponses:
182
192
  latency: float,
183
193
  output: Any,
184
194
  available_tool_calls: Optional[List[Dict[str, Any]]] = None,
195
+ model_from_response: Optional[str] = None,
185
196
  ):
186
197
  if posthog_trace_id is None:
187
198
  posthog_trace_id = str(uuid.uuid4())
188
199
 
200
+ # Use model from kwargs, fallback to model from response
201
+ model = kwargs.get("model") or model_from_response or "unknown"
202
+
189
203
  event_properties = {
190
204
  "$ai_provider": "openai",
191
- "$ai_model": kwargs.get("model"),
205
+ "$ai_model": model,
192
206
  "$ai_model_parameters": get_model_params(kwargs),
193
207
  "$ai_input": with_privacy_mode(
194
208
  self._client._ph_client,
@@ -350,6 +364,7 @@ class WrappedCompletions:
350
364
  usage_stats: TokenUsage = TokenUsage()
351
365
  accumulated_content = []
352
366
  accumulated_tool_calls: Dict[int, Dict[str, Any]] = {}
367
+ model_from_response: Optional[str] = None
353
368
 
354
369
  if "stream_options" not in kwargs:
355
370
  kwargs["stream_options"] = {}
@@ -360,9 +375,14 @@ class WrappedCompletions:
360
375
  nonlocal usage_stats
361
376
  nonlocal accumulated_content # noqa: F824
362
377
  nonlocal accumulated_tool_calls
378
+ nonlocal model_from_response
363
379
 
364
380
  try:
365
381
  async for chunk in response:
382
+ # Extract model from chunk (Chat Completions chunks have model field)
383
+ if model_from_response is None and hasattr(chunk, "model"):
384
+ model_from_response = chunk.model
385
+
366
386
  # Extract usage stats from chunk
367
387
  chunk_usage = extract_openai_usage_from_chunk(chunk, "chat")
368
388
  if chunk_usage:
@@ -405,6 +425,7 @@ class WrappedCompletions:
405
425
  accumulated_content,
406
426
  tool_calls_list,
407
427
  extract_available_tool_calls("openai", kwargs),
428
+ model_from_response,
408
429
  )
409
430
 
410
431
  return async_generator()
@@ -422,13 +443,17 @@ class WrappedCompletions:
422
443
  output: Any,
423
444
  tool_calls: Optional[List[Dict[str, Any]]] = None,
424
445
  available_tool_calls: Optional[List[Dict[str, Any]]] = None,
446
+ model_from_response: Optional[str] = None,
425
447
  ):
426
448
  if posthog_trace_id is None:
427
449
  posthog_trace_id = str(uuid.uuid4())
428
450
 
451
+ # Use model from kwargs, fallback to model from response
452
+ model = kwargs.get("model") or model_from_response or "unknown"
453
+
429
454
  event_properties = {
430
455
  "$ai_provider": "openai",
431
- "$ai_model": kwargs.get("model"),
456
+ "$ai_model": model,
432
457
  "$ai_model_parameters": get_model_params(kwargs),
433
458
  "$ai_input": with_privacy_mode(
434
459
  self._client._ph_client,
@@ -67,6 +67,12 @@ def format_openai_response(response: Any) -> List[FormattedMessage]:
67
67
  }
68
68
  )
69
69
 
70
+ # Handle audio output (gpt-4o-audio-preview)
71
+ if hasattr(choice.message, "audio") and choice.message.audio:
72
+ # Convert Pydantic model to dict to capture all fields from OpenAI
73
+ audio_dict = choice.message.audio.model_dump()
74
+ content.append({"type": "audio", **audio_dict})
75
+
70
76
  if content:
71
77
  output.append(
72
78
  {
@@ -1,3 +1,4 @@
1
+ import os
1
2
  import re
2
3
  from typing import Any
3
4
  from urllib.parse import urlparse
@@ -5,6 +6,15 @@ from urllib.parse import urlparse
5
6
  REDACTED_IMAGE_PLACEHOLDER = "[base64 image redacted]"
6
7
 
7
8
 
9
+ def _is_multimodal_enabled() -> bool:
10
+ """Check if multimodal capture is enabled via environment variable."""
11
+ return os.environ.get("_INTERNAL_LLMA_MULTIMODAL", "").lower() in (
12
+ "true",
13
+ "1",
14
+ "yes",
15
+ )
16
+
17
+
8
18
  def is_base64_data_url(text: str) -> bool:
9
19
  return re.match(r"^data:([^;]+);base64,", text) is not None
10
20
 
@@ -27,6 +37,9 @@ def is_raw_base64(text: str) -> bool:
27
37
 
28
38
 
29
39
  def redact_base64_data_url(value: Any) -> Any:
40
+ if _is_multimodal_enabled():
41
+ return value
42
+
30
43
  if not isinstance(value, str):
31
44
  return value
32
45
 
@@ -83,6 +96,11 @@ def sanitize_openai_image(item: Any) -> Any:
83
96
  },
84
97
  }
85
98
 
99
+ if item.get("type") == "audio" and "data" in item:
100
+ if _is_multimodal_enabled():
101
+ return item
102
+ return {**item, "data": REDACTED_IMAGE_PLACEHOLDER}
103
+
86
104
  return item
87
105
 
88
106
 
@@ -100,6 +118,9 @@ def sanitize_openai_response_image(item: Any) -> Any:
100
118
 
101
119
 
102
120
  def sanitize_anthropic_image(item: Any) -> Any:
121
+ if _is_multimodal_enabled():
122
+ return item
123
+
103
124
  if not isinstance(item, dict):
104
125
  return item
105
126
 
@@ -109,8 +130,6 @@ def sanitize_anthropic_image(item: Any) -> Any:
109
130
  and item["source"].get("type") == "base64"
110
131
  and "data" in item["source"]
111
132
  ):
112
- # For Anthropic, if the source type is "base64", we should always redact the data
113
- # The provider is explicitly telling us this is base64 data
114
133
  return {
115
134
  **item,
116
135
  "source": {
@@ -123,6 +142,9 @@ def sanitize_anthropic_image(item: Any) -> Any:
123
142
 
124
143
 
125
144
  def sanitize_gemini_part(part: Any) -> Any:
145
+ if _is_multimodal_enabled():
146
+ return part
147
+
126
148
  if not isinstance(part, dict):
127
149
  return part
128
150
 
@@ -131,8 +153,6 @@ def sanitize_gemini_part(part: Any) -> Any:
131
153
  and isinstance(part["inline_data"], dict)
132
154
  and "data" in part["inline_data"]
133
155
  ):
134
- # For Gemini, the inline_data structure indicates base64 data
135
- # We should redact any string data in this context
136
156
  return {
137
157
  **part,
138
158
  "inline_data": {
@@ -185,7 +205,9 @@ def sanitize_langchain_image(item: Any) -> Any:
185
205
  and isinstance(item.get("source"), dict)
186
206
  and "data" in item["source"]
187
207
  ):
188
- # Anthropic style - raw base64 in structured format, always redact
208
+ if _is_multimodal_enabled():
209
+ return item
210
+
189
211
  return {
190
212
  **item,
191
213
  "source": {
@@ -285,7 +285,7 @@ def call_llm_and_track_usage(
285
285
 
286
286
  event_properties = {
287
287
  "$ai_provider": provider,
288
- "$ai_model": kwargs.get("model"),
288
+ "$ai_model": kwargs.get("model") or getattr(response, "model", None),
289
289
  "$ai_model_parameters": get_model_params(kwargs),
290
290
  "$ai_input": with_privacy_mode(
291
291
  ph_client, posthog_privacy_mode, sanitized_messages
@@ -396,7 +396,7 @@ async def call_llm_and_track_usage_async(
396
396
 
397
397
  event_properties = {
398
398
  "$ai_provider": provider,
399
- "$ai_model": kwargs.get("model"),
399
+ "$ai_model": kwargs.get("model") or getattr(response, "model", None),
400
400
  "$ai_model_parameters": get_model_params(kwargs),
401
401
  "$ai_input": with_privacy_mode(
402
402
  ph_client, posthog_privacy_mode, sanitized_messages