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.
- posthoganalytics/__init__.py +10 -0
- posthoganalytics/ai/gemini/__init__.py +3 -0
- posthoganalytics/ai/gemini/gemini.py +1 -1
- posthoganalytics/ai/gemini/gemini_async.py +423 -0
- posthoganalytics/ai/gemini/gemini_converter.py +87 -21
- posthoganalytics/ai/openai/openai.py +27 -2
- posthoganalytics/ai/openai/openai_async.py +27 -2
- posthoganalytics/ai/openai/openai_converter.py +6 -0
- posthoganalytics/ai/sanitization.py +27 -5
- posthoganalytics/ai/utils.py +2 -2
- posthoganalytics/client.py +224 -58
- posthoganalytics/exception_utils.py +49 -4
- posthoganalytics/flag_definition_cache.py +127 -0
- posthoganalytics/request.py +203 -23
- posthoganalytics/test/test_client.py +207 -22
- posthoganalytics/test/test_exception_capture.py +45 -1
- posthoganalytics/test/test_feature_flag_result.py +441 -2
- posthoganalytics/test/test_feature_flags.py +166 -73
- posthoganalytics/test/test_flag_definition_cache.py +612 -0
- posthoganalytics/test/test_request.py +536 -0
- posthoganalytics/test/test_utils.py +4 -1
- posthoganalytics/types.py +40 -0
- posthoganalytics/version.py +1 -1
- {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/METADATA +2 -1
- {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/RECORD +28 -25
- {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/WHEEL +0 -0
- {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/licenses/LICENSE +0 -0
- {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=
|
|
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=
|
|
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":
|
|
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":
|
|
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
|
-
|
|
208
|
+
if _is_multimodal_enabled():
|
|
209
|
+
return item
|
|
210
|
+
|
|
189
211
|
return {
|
|
190
212
|
**item,
|
|
191
213
|
"source": {
|
posthoganalytics/ai/utils.py
CHANGED
|
@@ -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
|