posthog 6.7.13__py3-none-any.whl → 6.8.0__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.
- posthog/ai/anthropic/anthropic_async.py +30 -67
- posthog/ai/anthropic/anthropic_converter.py +40 -0
- posthog/ai/gemini/gemini_converter.py +73 -3
- posthog/ai/openai/openai_async.py +19 -0
- posthog/ai/openai/openai_converter.py +124 -0
- posthog/ai/types.py +1 -0
- posthog/ai/utils.py +30 -0
- posthog/client.py +1 -2
- posthog/integrations/django.py +81 -13
- posthog/version.py +1 -1
- {posthog-6.7.13.dist-info → posthog-6.8.0.dist-info}/METADATA +1 -1
- {posthog-6.7.13.dist-info → posthog-6.8.0.dist-info}/RECORD +15 -15
- {posthog-6.7.13.dist-info → posthog-6.8.0.dist-info}/WHEEL +0 -0
- {posthog-6.7.13.dist-info → posthog-6.8.0.dist-info}/licenses/LICENSE +0 -0
- {posthog-6.7.13.dist-info → posthog-6.8.0.dist-info}/top_level.txt +0 -0
|
@@ -14,14 +14,9 @@ from posthog import setup
|
|
|
14
14
|
from posthog.ai.types import StreamingContentBlock, TokenUsage, ToolInProgress
|
|
15
15
|
from posthog.ai.utils import (
|
|
16
16
|
call_llm_and_track_usage_async,
|
|
17
|
-
extract_available_tool_calls,
|
|
18
|
-
get_model_params,
|
|
19
|
-
merge_system_prompt,
|
|
20
17
|
merge_usage_stats,
|
|
21
|
-
with_privacy_mode,
|
|
22
18
|
)
|
|
23
19
|
from posthog.ai.anthropic.anthropic_converter import (
|
|
24
|
-
format_anthropic_streaming_content,
|
|
25
20
|
extract_anthropic_usage_from_event,
|
|
26
21
|
handle_anthropic_content_block_start,
|
|
27
22
|
handle_anthropic_text_delta,
|
|
@@ -220,66 +215,34 @@ class AsyncWrappedMessages(AsyncMessages):
|
|
|
220
215
|
content_blocks: List[StreamingContentBlock],
|
|
221
216
|
accumulated_content: str,
|
|
222
217
|
):
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
"$ai_provider": "anthropic",
|
|
243
|
-
"$ai_model": kwargs.get("model"),
|
|
244
|
-
"$ai_model_parameters": get_model_params(kwargs),
|
|
245
|
-
"$ai_input": with_privacy_mode(
|
|
246
|
-
self._client._ph_client,
|
|
247
|
-
posthog_privacy_mode,
|
|
248
|
-
sanitize_anthropic(merge_system_prompt(kwargs, "anthropic")),
|
|
249
|
-
),
|
|
250
|
-
"$ai_output_choices": with_privacy_mode(
|
|
251
|
-
self._client._ph_client,
|
|
252
|
-
posthog_privacy_mode,
|
|
253
|
-
formatted_output,
|
|
254
|
-
),
|
|
255
|
-
"$ai_http_status": 200,
|
|
256
|
-
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
|
|
257
|
-
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
|
|
258
|
-
"$ai_cache_read_input_tokens": usage_stats.get(
|
|
259
|
-
"cache_read_input_tokens", 0
|
|
260
|
-
),
|
|
261
|
-
"$ai_cache_creation_input_tokens": usage_stats.get(
|
|
262
|
-
"cache_creation_input_tokens", 0
|
|
218
|
+
from posthog.ai.types import StreamingEventData
|
|
219
|
+
from posthog.ai.anthropic.anthropic_converter import (
|
|
220
|
+
format_anthropic_streaming_input,
|
|
221
|
+
format_anthropic_streaming_output_complete,
|
|
222
|
+
)
|
|
223
|
+
from posthog.ai.utils import capture_streaming_event
|
|
224
|
+
|
|
225
|
+
# Prepare standardized event data
|
|
226
|
+
formatted_input = format_anthropic_streaming_input(kwargs)
|
|
227
|
+
sanitized_input = sanitize_anthropic(formatted_input)
|
|
228
|
+
|
|
229
|
+
event_data = StreamingEventData(
|
|
230
|
+
provider="anthropic",
|
|
231
|
+
model=kwargs.get("model", "unknown"),
|
|
232
|
+
base_url=str(self._client.base_url),
|
|
233
|
+
kwargs=kwargs,
|
|
234
|
+
formatted_input=sanitized_input,
|
|
235
|
+
formatted_output=format_anthropic_streaming_output_complete(
|
|
236
|
+
content_blocks, accumulated_content
|
|
263
237
|
),
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if posthog_distinct_id is None:
|
|
277
|
-
event_properties["$process_person_profile"] = False
|
|
278
|
-
|
|
279
|
-
if hasattr(self._client._ph_client, "capture"):
|
|
280
|
-
self._client._ph_client.capture(
|
|
281
|
-
distinct_id=posthog_distinct_id or posthog_trace_id,
|
|
282
|
-
event="$ai_generation",
|
|
283
|
-
properties=event_properties,
|
|
284
|
-
groups=posthog_groups,
|
|
285
|
-
)
|
|
238
|
+
usage_stats=usage_stats,
|
|
239
|
+
latency=latency,
|
|
240
|
+
distinct_id=posthog_distinct_id,
|
|
241
|
+
trace_id=posthog_trace_id,
|
|
242
|
+
properties=posthog_properties,
|
|
243
|
+
privacy_mode=posthog_privacy_mode,
|
|
244
|
+
groups=posthog_groups,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Use the common capture function
|
|
248
|
+
capture_streaming_event(self._client._ph_client, event_data)
|
|
@@ -163,6 +163,32 @@ def format_anthropic_streaming_content(
|
|
|
163
163
|
return formatted
|
|
164
164
|
|
|
165
165
|
|
|
166
|
+
def extract_anthropic_web_search_count(response: Any) -> int:
|
|
167
|
+
"""
|
|
168
|
+
Extract web search count from Anthropic response.
|
|
169
|
+
|
|
170
|
+
Anthropic provides exact web search counts via usage.server_tool_use.web_search_requests.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
response: The response from Anthropic API
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Number of web search requests (0 if none)
|
|
177
|
+
"""
|
|
178
|
+
if not hasattr(response, "usage"):
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
if not hasattr(response.usage, "server_tool_use"):
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
server_tool_use = response.usage.server_tool_use
|
|
185
|
+
|
|
186
|
+
if hasattr(server_tool_use, "web_search_requests"):
|
|
187
|
+
return max(0, int(getattr(server_tool_use, "web_search_requests", 0)))
|
|
188
|
+
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
|
|
166
192
|
def extract_anthropic_usage_from_response(response: Any) -> TokenUsage:
|
|
167
193
|
"""
|
|
168
194
|
Extract usage from a full Anthropic response (non-streaming).
|
|
@@ -191,6 +217,10 @@ def extract_anthropic_usage_from_response(response: Any) -> TokenUsage:
|
|
|
191
217
|
if cache_creation and cache_creation > 0:
|
|
192
218
|
result["cache_creation_input_tokens"] = cache_creation
|
|
193
219
|
|
|
220
|
+
web_search_count = extract_anthropic_web_search_count(response)
|
|
221
|
+
if web_search_count > 0:
|
|
222
|
+
result["web_search_count"] = web_search_count
|
|
223
|
+
|
|
194
224
|
return result
|
|
195
225
|
|
|
196
226
|
|
|
@@ -222,6 +252,16 @@ def extract_anthropic_usage_from_event(event: Any) -> TokenUsage:
|
|
|
222
252
|
if hasattr(event, "usage") and event.usage:
|
|
223
253
|
usage["output_tokens"] = getattr(event.usage, "output_tokens", 0)
|
|
224
254
|
|
|
255
|
+
# Extract web search count from usage
|
|
256
|
+
if hasattr(event.usage, "server_tool_use"):
|
|
257
|
+
server_tool_use = event.usage.server_tool_use
|
|
258
|
+
if hasattr(server_tool_use, "web_search_requests"):
|
|
259
|
+
web_search_count = int(
|
|
260
|
+
getattr(server_tool_use, "web_search_requests", 0)
|
|
261
|
+
)
|
|
262
|
+
if web_search_count > 0:
|
|
263
|
+
usage["web_search_count"] = web_search_count
|
|
264
|
+
|
|
225
265
|
return usage
|
|
226
266
|
|
|
227
267
|
|
|
@@ -338,6 +338,61 @@ def format_gemini_input(contents: Any) -> List[FormattedMessage]:
|
|
|
338
338
|
return [_format_object_message(contents)]
|
|
339
339
|
|
|
340
340
|
|
|
341
|
+
def extract_gemini_web_search_count(response: Any) -> int:
|
|
342
|
+
"""
|
|
343
|
+
Extract web search count from Gemini response.
|
|
344
|
+
|
|
345
|
+
Gemini bills per request that uses grounding, not per query.
|
|
346
|
+
Returns 1 if grounding_metadata is present with actual search data, 0 otherwise.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
response: The response from Gemini API
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
1 if web search/grounding was used, 0 otherwise
|
|
353
|
+
"""
|
|
354
|
+
|
|
355
|
+
# Check for grounding_metadata in candidates
|
|
356
|
+
if hasattr(response, "candidates"):
|
|
357
|
+
for candidate in response.candidates:
|
|
358
|
+
if (
|
|
359
|
+
hasattr(candidate, "grounding_metadata")
|
|
360
|
+
and candidate.grounding_metadata
|
|
361
|
+
):
|
|
362
|
+
grounding_metadata = candidate.grounding_metadata
|
|
363
|
+
|
|
364
|
+
# Check if web_search_queries exists and is non-empty
|
|
365
|
+
if hasattr(grounding_metadata, "web_search_queries"):
|
|
366
|
+
queries = grounding_metadata.web_search_queries
|
|
367
|
+
|
|
368
|
+
if queries is not None and len(queries) > 0:
|
|
369
|
+
return 1
|
|
370
|
+
|
|
371
|
+
# Check if grounding_chunks exists and is non-empty
|
|
372
|
+
if hasattr(grounding_metadata, "grounding_chunks"):
|
|
373
|
+
chunks = grounding_metadata.grounding_chunks
|
|
374
|
+
|
|
375
|
+
if chunks is not None and len(chunks) > 0:
|
|
376
|
+
return 1
|
|
377
|
+
|
|
378
|
+
# Also check for google_search or grounding in function call names
|
|
379
|
+
if hasattr(candidate, "content") and candidate.content:
|
|
380
|
+
if hasattr(candidate.content, "parts") and candidate.content.parts:
|
|
381
|
+
for part in candidate.content.parts:
|
|
382
|
+
if hasattr(part, "function_call") and part.function_call:
|
|
383
|
+
function_name = getattr(
|
|
384
|
+
part.function_call, "name", ""
|
|
385
|
+
).lower()
|
|
386
|
+
|
|
387
|
+
if (
|
|
388
|
+
"google_search" in function_name
|
|
389
|
+
or "grounding" in function_name
|
|
390
|
+
):
|
|
391
|
+
return 1
|
|
392
|
+
|
|
393
|
+
return 0
|
|
394
|
+
|
|
395
|
+
|
|
341
396
|
def _extract_usage_from_metadata(metadata: Any) -> TokenUsage:
|
|
342
397
|
"""
|
|
343
398
|
Common logic to extract usage from Gemini metadata.
|
|
@@ -382,7 +437,14 @@ def extract_gemini_usage_from_response(response: Any) -> TokenUsage:
|
|
|
382
437
|
if not hasattr(response, "usage_metadata") or not response.usage_metadata:
|
|
383
438
|
return TokenUsage(input_tokens=0, output_tokens=0)
|
|
384
439
|
|
|
385
|
-
|
|
440
|
+
usage = _extract_usage_from_metadata(response.usage_metadata)
|
|
441
|
+
|
|
442
|
+
# Add web search count if present
|
|
443
|
+
web_search_count = extract_gemini_web_search_count(response)
|
|
444
|
+
if web_search_count > 0:
|
|
445
|
+
usage["web_search_count"] = web_search_count
|
|
446
|
+
|
|
447
|
+
return usage
|
|
386
448
|
|
|
387
449
|
|
|
388
450
|
def extract_gemini_usage_from_chunk(chunk: Any) -> TokenUsage:
|
|
@@ -398,11 +460,19 @@ def extract_gemini_usage_from_chunk(chunk: Any) -> TokenUsage:
|
|
|
398
460
|
|
|
399
461
|
usage: TokenUsage = TokenUsage()
|
|
400
462
|
|
|
463
|
+
# Extract web search count from the chunk before checking for usage_metadata
|
|
464
|
+
# Web search indicators can appear on any chunk, not just those with usage data
|
|
465
|
+
web_search_count = extract_gemini_web_search_count(chunk)
|
|
466
|
+
if web_search_count > 0:
|
|
467
|
+
usage["web_search_count"] = web_search_count
|
|
468
|
+
|
|
401
469
|
if not hasattr(chunk, "usage_metadata") or not chunk.usage_metadata:
|
|
402
470
|
return usage
|
|
403
471
|
|
|
404
|
-
|
|
405
|
-
|
|
472
|
+
usage_from_metadata = _extract_usage_from_metadata(chunk.usage_metadata)
|
|
473
|
+
|
|
474
|
+
# Merge the usage from metadata with any web search count we found
|
|
475
|
+
usage.update(usage_from_metadata)
|
|
406
476
|
|
|
407
477
|
return usage
|
|
408
478
|
|
|
@@ -213,6 +213,15 @@ class WrappedResponses:
|
|
|
213
213
|
**(posthog_properties or {}),
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
# Add web search count if present
|
|
217
|
+
web_search_count = usage_stats.get("web_search_count")
|
|
218
|
+
if (
|
|
219
|
+
web_search_count is not None
|
|
220
|
+
and isinstance(web_search_count, int)
|
|
221
|
+
and web_search_count > 0
|
|
222
|
+
):
|
|
223
|
+
event_properties["$ai_web_search_count"] = web_search_count
|
|
224
|
+
|
|
216
225
|
if available_tool_calls:
|
|
217
226
|
event_properties["$ai_tools"] = available_tool_calls
|
|
218
227
|
|
|
@@ -444,6 +453,16 @@ class WrappedCompletions:
|
|
|
444
453
|
**(posthog_properties or {}),
|
|
445
454
|
}
|
|
446
455
|
|
|
456
|
+
# Add web search count if present
|
|
457
|
+
web_search_count = usage_stats.get("web_search_count")
|
|
458
|
+
|
|
459
|
+
if (
|
|
460
|
+
web_search_count is not None
|
|
461
|
+
and isinstance(web_search_count, int)
|
|
462
|
+
and web_search_count > 0
|
|
463
|
+
):
|
|
464
|
+
event_properties["$ai_web_search_count"] = web_search_count
|
|
465
|
+
|
|
447
466
|
if available_tool_calls:
|
|
448
467
|
event_properties["$ai_tools"] = available_tool_calls
|
|
449
468
|
|
|
@@ -255,6 +255,113 @@ def format_openai_streaming_content(
|
|
|
255
255
|
return formatted
|
|
256
256
|
|
|
257
257
|
|
|
258
|
+
def extract_openai_web_search_count(response: Any) -> int:
|
|
259
|
+
"""
|
|
260
|
+
Extract web search count from OpenAI response.
|
|
261
|
+
|
|
262
|
+
Uses a two-tier detection strategy:
|
|
263
|
+
1. Priority 1 (exact count): Check for output[].type == "web_search_call" (Responses API)
|
|
264
|
+
2. Priority 2 (binary detection): Check for various web search indicators:
|
|
265
|
+
- Root-level citations, search_results, or usage.search_context_size (Perplexity)
|
|
266
|
+
- Annotations with type "url_citation" in choices/output (including delta for streaming)
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
response: The response from OpenAI API
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Number of web search requests (exact count or binary 1/0)
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
# Priority 1: Check for exact count in Responses API output
|
|
276
|
+
if hasattr(response, "output"):
|
|
277
|
+
web_search_count = 0
|
|
278
|
+
|
|
279
|
+
for item in response.output:
|
|
280
|
+
if hasattr(item, "type") and item.type == "web_search_call":
|
|
281
|
+
web_search_count += 1
|
|
282
|
+
|
|
283
|
+
web_search_count = max(0, web_search_count)
|
|
284
|
+
|
|
285
|
+
if web_search_count > 0:
|
|
286
|
+
return web_search_count
|
|
287
|
+
|
|
288
|
+
# Priority 2: Binary detection (returns 1 or 0)
|
|
289
|
+
|
|
290
|
+
# Check root-level indicators (Perplexity)
|
|
291
|
+
if hasattr(response, "citations"):
|
|
292
|
+
citations = getattr(response, "citations")
|
|
293
|
+
|
|
294
|
+
if citations and len(citations) > 0:
|
|
295
|
+
return 1
|
|
296
|
+
|
|
297
|
+
if hasattr(response, "search_results"):
|
|
298
|
+
search_results = getattr(response, "search_results")
|
|
299
|
+
|
|
300
|
+
if search_results and len(search_results) > 0:
|
|
301
|
+
return 1
|
|
302
|
+
|
|
303
|
+
if hasattr(response, "usage") and hasattr(response.usage, "search_context_size"):
|
|
304
|
+
if response.usage.search_context_size:
|
|
305
|
+
return 1
|
|
306
|
+
|
|
307
|
+
# Check for url_citation annotations in choices (Chat Completions)
|
|
308
|
+
if hasattr(response, "choices"):
|
|
309
|
+
for choice in response.choices:
|
|
310
|
+
# Check message.annotations (non-streaming or final chunk)
|
|
311
|
+
if hasattr(choice, "message") and hasattr(choice.message, "annotations"):
|
|
312
|
+
annotations = choice.message.annotations
|
|
313
|
+
|
|
314
|
+
if annotations:
|
|
315
|
+
for annotation in annotations:
|
|
316
|
+
# Support both dict and object formats
|
|
317
|
+
annotation_type = (
|
|
318
|
+
annotation.get("type")
|
|
319
|
+
if isinstance(annotation, dict)
|
|
320
|
+
else getattr(annotation, "type", None)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
if annotation_type == "url_citation":
|
|
324
|
+
return 1
|
|
325
|
+
|
|
326
|
+
# Check delta.annotations (streaming chunks)
|
|
327
|
+
if hasattr(choice, "delta") and hasattr(choice.delta, "annotations"):
|
|
328
|
+
annotations = choice.delta.annotations
|
|
329
|
+
|
|
330
|
+
if annotations:
|
|
331
|
+
for annotation in annotations:
|
|
332
|
+
# Support both dict and object formats
|
|
333
|
+
annotation_type = (
|
|
334
|
+
annotation.get("type")
|
|
335
|
+
if isinstance(annotation, dict)
|
|
336
|
+
else getattr(annotation, "type", None)
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
if annotation_type == "url_citation":
|
|
340
|
+
return 1
|
|
341
|
+
|
|
342
|
+
# Check for url_citation annotations in output (Responses API)
|
|
343
|
+
if hasattr(response, "output"):
|
|
344
|
+
for item in response.output:
|
|
345
|
+
if hasattr(item, "content") and isinstance(item.content, list):
|
|
346
|
+
for content_item in item.content:
|
|
347
|
+
if hasattr(content_item, "annotations"):
|
|
348
|
+
annotations = content_item.annotations
|
|
349
|
+
|
|
350
|
+
if annotations:
|
|
351
|
+
for annotation in annotations:
|
|
352
|
+
# Support both dict and object formats
|
|
353
|
+
annotation_type = (
|
|
354
|
+
annotation.get("type")
|
|
355
|
+
if isinstance(annotation, dict)
|
|
356
|
+
else getattr(annotation, "type", None)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if annotation_type == "url_citation":
|
|
360
|
+
return 1
|
|
361
|
+
|
|
362
|
+
return 0
|
|
363
|
+
|
|
364
|
+
|
|
258
365
|
def extract_openai_usage_from_response(response: Any) -> TokenUsage:
|
|
259
366
|
"""
|
|
260
367
|
Extract usage statistics from a full OpenAI response (non-streaming).
|
|
@@ -312,6 +419,10 @@ def extract_openai_usage_from_response(response: Any) -> TokenUsage:
|
|
|
312
419
|
if reasoning_tokens > 0:
|
|
313
420
|
result["reasoning_tokens"] = reasoning_tokens
|
|
314
421
|
|
|
422
|
+
web_search_count = extract_openai_web_search_count(response)
|
|
423
|
+
if web_search_count > 0:
|
|
424
|
+
result["web_search_count"] = web_search_count
|
|
425
|
+
|
|
315
426
|
return result
|
|
316
427
|
|
|
317
428
|
|
|
@@ -334,6 +445,13 @@ def extract_openai_usage_from_chunk(
|
|
|
334
445
|
usage: TokenUsage = TokenUsage()
|
|
335
446
|
|
|
336
447
|
if provider_type == "chat":
|
|
448
|
+
# Extract web search count from the chunk before checking for usage
|
|
449
|
+
# Web search indicators (citations, annotations) can appear on any chunk,
|
|
450
|
+
# not just those with usage data
|
|
451
|
+
web_search_count = extract_openai_web_search_count(chunk)
|
|
452
|
+
if web_search_count > 0:
|
|
453
|
+
usage["web_search_count"] = web_search_count
|
|
454
|
+
|
|
337
455
|
if not hasattr(chunk, "usage") or not chunk.usage:
|
|
338
456
|
return usage
|
|
339
457
|
|
|
@@ -386,6 +504,12 @@ def extract_openai_usage_from_chunk(
|
|
|
386
504
|
response_usage.output_tokens_details.reasoning_tokens
|
|
387
505
|
)
|
|
388
506
|
|
|
507
|
+
# Extract web search count from the complete response
|
|
508
|
+
if hasattr(chunk, "response"):
|
|
509
|
+
web_search_count = extract_openai_web_search_count(chunk.response)
|
|
510
|
+
if web_search_count > 0:
|
|
511
|
+
usage["web_search_count"] = web_search_count
|
|
512
|
+
|
|
389
513
|
return usage
|
|
390
514
|
|
|
391
515
|
|
posthog/ai/types.py
CHANGED
posthog/ai/utils.py
CHANGED
|
@@ -53,6 +53,12 @@ def merge_usage_stats(
|
|
|
53
53
|
if source_reasoning is not None:
|
|
54
54
|
current = target.get("reasoning_tokens") or 0
|
|
55
55
|
target["reasoning_tokens"] = current + source_reasoning
|
|
56
|
+
|
|
57
|
+
source_web_search = source.get("web_search_count")
|
|
58
|
+
if source_web_search is not None:
|
|
59
|
+
current = target.get("web_search_count") or 0
|
|
60
|
+
target["web_search_count"] = max(current, source_web_search)
|
|
61
|
+
|
|
56
62
|
elif mode == "cumulative":
|
|
57
63
|
# Replace with latest values (already cumulative)
|
|
58
64
|
if source.get("input_tokens") is not None:
|
|
@@ -67,6 +73,9 @@ def merge_usage_stats(
|
|
|
67
73
|
]
|
|
68
74
|
if source.get("reasoning_tokens") is not None:
|
|
69
75
|
target["reasoning_tokens"] = source["reasoning_tokens"]
|
|
76
|
+
if source.get("web_search_count") is not None:
|
|
77
|
+
target["web_search_count"] = source["web_search_count"]
|
|
78
|
+
|
|
70
79
|
else:
|
|
71
80
|
raise ValueError(f"Invalid mode: {mode}. Must be 'incremental' or 'cumulative'")
|
|
72
81
|
|
|
@@ -311,6 +320,10 @@ def call_llm_and_track_usage(
|
|
|
311
320
|
if reasoning is not None and reasoning > 0:
|
|
312
321
|
event_properties["$ai_reasoning_tokens"] = reasoning
|
|
313
322
|
|
|
323
|
+
web_search_count = usage.get("web_search_count")
|
|
324
|
+
if web_search_count is not None and web_search_count > 0:
|
|
325
|
+
event_properties["$ai_web_search_count"] = web_search_count
|
|
326
|
+
|
|
314
327
|
if posthog_distinct_id is None:
|
|
315
328
|
event_properties["$process_person_profile"] = False
|
|
316
329
|
|
|
@@ -414,6 +427,14 @@ async def call_llm_and_track_usage_async(
|
|
|
414
427
|
if cache_creation is not None and cache_creation > 0:
|
|
415
428
|
event_properties["$ai_cache_creation_input_tokens"] = cache_creation
|
|
416
429
|
|
|
430
|
+
reasoning = usage.get("reasoning_tokens")
|
|
431
|
+
if reasoning is not None and reasoning > 0:
|
|
432
|
+
event_properties["$ai_reasoning_tokens"] = reasoning
|
|
433
|
+
|
|
434
|
+
web_search_count = usage.get("web_search_count")
|
|
435
|
+
if web_search_count is not None and web_search_count > 0:
|
|
436
|
+
event_properties["$ai_web_search_count"] = web_search_count
|
|
437
|
+
|
|
417
438
|
if posthog_distinct_id is None:
|
|
418
439
|
event_properties["$process_person_profile"] = False
|
|
419
440
|
|
|
@@ -535,6 +556,15 @@ def capture_streaming_event(
|
|
|
535
556
|
if value is not None and isinstance(value, int) and value > 0:
|
|
536
557
|
event_properties[f"$ai_{field}"] = value
|
|
537
558
|
|
|
559
|
+
# Add web search count if present (all providers)
|
|
560
|
+
web_search_count = event_data["usage_stats"].get("web_search_count")
|
|
561
|
+
if (
|
|
562
|
+
web_search_count is not None
|
|
563
|
+
and isinstance(web_search_count, int)
|
|
564
|
+
and web_search_count > 0
|
|
565
|
+
):
|
|
566
|
+
event_properties["$ai_web_search_count"] = web_search_count
|
|
567
|
+
|
|
538
568
|
# Handle provider-specific fields
|
|
539
569
|
if (
|
|
540
570
|
event_data["provider"] == "openai"
|
posthog/client.py
CHANGED
|
@@ -3,7 +3,7 @@ import logging
|
|
|
3
3
|
import os
|
|
4
4
|
import sys
|
|
5
5
|
from datetime import datetime, timedelta
|
|
6
|
-
from typing import Any,
|
|
6
|
+
from typing import Any, Dict, Optional, Union
|
|
7
7
|
from typing_extensions import Unpack
|
|
8
8
|
from uuid import uuid4
|
|
9
9
|
|
|
@@ -60,7 +60,6 @@ from posthog.utils import (
|
|
|
60
60
|
SizeLimitedDict,
|
|
61
61
|
clean,
|
|
62
62
|
guess_timezone,
|
|
63
|
-
remove_trailing_slash,
|
|
64
63
|
system_context,
|
|
65
64
|
)
|
|
66
65
|
from posthog.version import VERSION
|
posthog/integrations/django.py
CHANGED
|
@@ -112,9 +112,18 @@ class PosthogContextMiddleware:
|
|
|
112
112
|
|
|
113
113
|
def extract_tags(self, request):
|
|
114
114
|
# type: (HttpRequest) -> Dict[str, Any]
|
|
115
|
-
tags
|
|
115
|
+
"""Extract tags from request in sync context."""
|
|
116
|
+
user_id, user_email = self.extract_request_user(request)
|
|
117
|
+
return self._build_tags(request, user_id, user_email)
|
|
118
|
+
|
|
119
|
+
def _build_tags(self, request, user_id, user_email):
|
|
120
|
+
# type: (HttpRequest, Optional[str], Optional[str]) -> Dict[str, Any]
|
|
121
|
+
"""
|
|
122
|
+
Build tags dict from request and user info.
|
|
116
123
|
|
|
117
|
-
|
|
124
|
+
Centralized tag extraction logic used by both sync and async paths.
|
|
125
|
+
"""
|
|
126
|
+
tags = {}
|
|
118
127
|
|
|
119
128
|
# Extract session ID from X-POSTHOG-SESSION-ID header
|
|
120
129
|
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
|
|
@@ -166,21 +175,78 @@ class PosthogContextMiddleware:
|
|
|
166
175
|
return tags
|
|
167
176
|
|
|
168
177
|
def extract_request_user(self, request):
|
|
169
|
-
|
|
170
|
-
email
|
|
171
|
-
|
|
178
|
+
# type: (HttpRequest) -> tuple[Optional[str], Optional[str]]
|
|
179
|
+
"""Extract user ID and email from request in sync context."""
|
|
172
180
|
user = getattr(request, "user", None)
|
|
181
|
+
return self._resolve_user_details(user)
|
|
173
182
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
183
|
+
async def aextract_tags(self, request):
|
|
184
|
+
# type: (HttpRequest) -> Dict[str, Any]
|
|
185
|
+
"""
|
|
186
|
+
Async version of extract_tags for use in async request handling.
|
|
187
|
+
|
|
188
|
+
Uses await request.auser() instead of request.user to avoid
|
|
189
|
+
SynchronousOnlyOperation in async context.
|
|
190
|
+
|
|
191
|
+
Follows Django's naming convention for async methods (auser, asave, etc.).
|
|
192
|
+
"""
|
|
193
|
+
user_id, user_email = await self.aextract_request_user(request)
|
|
194
|
+
return self._build_tags(request, user_id, user_email)
|
|
195
|
+
|
|
196
|
+
async def aextract_request_user(self, request):
|
|
197
|
+
# type: (HttpRequest) -> tuple[Optional[str], Optional[str]]
|
|
198
|
+
"""
|
|
199
|
+
Async version of extract_request_user for use in async request handling.
|
|
179
200
|
|
|
201
|
+
Uses await request.auser() instead of request.user to avoid
|
|
202
|
+
SynchronousOnlyOperation in async context.
|
|
203
|
+
|
|
204
|
+
Follows Django's naming convention for async methods (auser, asave, etc.).
|
|
205
|
+
"""
|
|
206
|
+
auser = getattr(request, "auser", None)
|
|
207
|
+
if callable(auser):
|
|
180
208
|
try:
|
|
181
|
-
|
|
209
|
+
user = await auser()
|
|
210
|
+
return self._resolve_user_details(user)
|
|
182
211
|
except Exception:
|
|
183
|
-
|
|
212
|
+
# If auser() fails, return empty - don't break the request
|
|
213
|
+
# Real errors (permissions, broken auth) will be logged by Django
|
|
214
|
+
return None, None
|
|
215
|
+
|
|
216
|
+
# Fallback for test requests without auser
|
|
217
|
+
return None, None
|
|
218
|
+
|
|
219
|
+
def _resolve_user_details(self, user):
|
|
220
|
+
# type: (Any) -> tuple[Optional[str], Optional[str]]
|
|
221
|
+
"""
|
|
222
|
+
Extract user ID and email from a user object.
|
|
223
|
+
|
|
224
|
+
Handles both authenticated and unauthenticated users, as well as
|
|
225
|
+
legacy Django where is_authenticated was a method.
|
|
226
|
+
"""
|
|
227
|
+
user_id = None
|
|
228
|
+
email = None
|
|
229
|
+
|
|
230
|
+
if user is None:
|
|
231
|
+
return user_id, email
|
|
232
|
+
|
|
233
|
+
# Handle is_authenticated (property in modern Django, method in legacy)
|
|
234
|
+
is_authenticated = getattr(user, "is_authenticated", False)
|
|
235
|
+
if callable(is_authenticated):
|
|
236
|
+
is_authenticated = is_authenticated()
|
|
237
|
+
|
|
238
|
+
if not is_authenticated:
|
|
239
|
+
return user_id, email
|
|
240
|
+
|
|
241
|
+
# Extract user primary key
|
|
242
|
+
user_pk = getattr(user, "pk", None)
|
|
243
|
+
if user_pk is not None:
|
|
244
|
+
user_id = str(user_pk)
|
|
245
|
+
|
|
246
|
+
# Extract user email
|
|
247
|
+
user_email = getattr(user, "email", None)
|
|
248
|
+
if user_email:
|
|
249
|
+
email = str(user_email)
|
|
184
250
|
|
|
185
251
|
return user_id, email
|
|
186
252
|
|
|
@@ -211,12 +277,14 @@ class PosthogContextMiddleware:
|
|
|
211
277
|
Asynchronous entry point for async request handling.
|
|
212
278
|
|
|
213
279
|
This method is called when the middleware chain is async.
|
|
280
|
+
Uses aextract_tags() which calls request.auser() to avoid
|
|
281
|
+
SynchronousOnlyOperation when accessing user in async context.
|
|
214
282
|
"""
|
|
215
283
|
if self.request_filter and not self.request_filter(request):
|
|
216
284
|
return await self.get_response(request)
|
|
217
285
|
|
|
218
286
|
with contexts.new_context(self.capture_exceptions, client=self.client):
|
|
219
|
-
for k, v in self.
|
|
287
|
+
for k, v in (await self.aextract_tags(request)).items():
|
|
220
288
|
contexts.tag(k, v)
|
|
221
289
|
|
|
222
290
|
return await self.get_response(request)
|
posthog/version.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
posthog/__init__.py,sha256=2TAAWFhDImqv2_5muq5WcRPXYzdYBDE2XUbH4hnpqeM,25845
|
|
2
2
|
posthog/args.py,sha256=JUt0vbtF33IzLt3ARgsxMEYYnZo3RNS_LcK4-CjWaco,3298
|
|
3
|
-
posthog/client.py,sha256=
|
|
3
|
+
posthog/client.py,sha256=vmtv214VMDTvl2H2S8TreSChCWQDMoOrxrRgfmT4ApM,72587
|
|
4
4
|
posthog/consumer.py,sha256=fdteMZ-deJGMpaQmHyznw_cwQG2Vvld1tmN9LUkZPrY,4608
|
|
5
5
|
posthog/contexts.py,sha256=FWdM84ibI7jJEKpNGVnjTXi7PWBQRpDUjLOuFkLxFYI,9387
|
|
6
6
|
posthog/exception_capture.py,sha256=pmKtjQ6QY6zs4u_-ZA4H1gCyR3iI4sfqCQG_jwe_bKo,1774
|
|
@@ -11,28 +11,28 @@ posthog/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
11
11
|
posthog/request.py,sha256=CaONBN7a5RD8xiSShVMgHEd9XxKWM6ZQTLZypiqABhA,6168
|
|
12
12
|
posthog/types.py,sha256=Dl3aFGX9XUR0wMmK12r2s5Hjan9jL4HpQ9GHpVcEq5U,10207
|
|
13
13
|
posthog/utils.py,sha256=-0w-OLcCaoldkbBebPzQyBzLJSo9G9yBOg8NDVz7La8,16088
|
|
14
|
-
posthog/version.py,sha256=
|
|
14
|
+
posthog/version.py,sha256=xqPIL3Vi5H0V2bZOTQqSHr-lQXEhP8VBNZcmOrTSRUw,87
|
|
15
15
|
posthog/ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
16
|
posthog/ai/sanitization.py,sha256=owipZ4eJYtd4JTI-CM_klatclXaeaIec3XJBOUfsOnQ,5770
|
|
17
|
-
posthog/ai/types.py,sha256=
|
|
18
|
-
posthog/ai/utils.py,sha256=
|
|
17
|
+
posthog/ai/types.py,sha256=arX98hR1PIPeJ3vFikxTlACIh1xPp6aEUw1gBLcKoB0,3273
|
|
18
|
+
posthog/ai/utils.py,sha256=pMqL_Aydf08EvUuSVJ1SsIpNwaom6qYIoLNOvMBNSHU,21475
|
|
19
19
|
posthog/ai/anthropic/__init__.py,sha256=8nTvETZzkfW-P3zBMmp06GOHs0N-xyOGu7Oa4di_lno,669
|
|
20
20
|
posthog/ai/anthropic/anthropic.py,sha256=UWyM6ryl5_VNQImaBi1RHN7tKXwkqaxy4yaXyPSkDp8,8669
|
|
21
|
-
posthog/ai/anthropic/anthropic_async.py,sha256=
|
|
22
|
-
posthog/ai/anthropic/anthropic_converter.py,sha256=
|
|
21
|
+
posthog/ai/anthropic/anthropic_async.py,sha256=ppWHiVp4hTl62Zr3jIwXXidOsqhrwx6iHM3ukG7WiPM,8789
|
|
22
|
+
posthog/ai/anthropic/anthropic_converter.py,sha256=prvaxt_R9kn9IbkxG_mLrw4kexT4i6T80U-6yhaZCNk,13053
|
|
23
23
|
posthog/ai/anthropic/anthropic_providers.py,sha256=Q_v7U4wgieIkvii-Bqh4pLx5pEgbrHmgsCG8lUkKb_0,2103
|
|
24
24
|
posthog/ai/gemini/__init__.py,sha256=JV_9-gBR87leHgZW4XAYZP7LSl4YaXeuhqDUpA8HygA,383
|
|
25
25
|
posthog/ai/gemini/gemini.py,sha256=-c2MnBeask6SrAbFZ7XXZ_OMcuglTBRdnFe_ROVgXWQ,14972
|
|
26
|
-
posthog/ai/gemini/gemini_converter.py,sha256=
|
|
26
|
+
posthog/ai/gemini/gemini_converter.py,sha256=WFF1gzLGk1DId-1yrA9nDYMd9PXgbVsyhU3wgKjAJTE,18731
|
|
27
27
|
posthog/ai/langchain/__init__.py,sha256=9CqAwLynTGj3ASAR80C3PmdTdrYGmu99tz0JL-HPFgI,70
|
|
28
28
|
posthog/ai/langchain/callbacks.py,sha256=5cjBFTNmHYhWxDWSAjIRfHvTebO8M6D5D37CR9vvoAg,30261
|
|
29
29
|
posthog/ai/openai/__init__.py,sha256=u4OuUT7k1NgFj0TrxjuyegOg7a_UA8nAU6a-Hszr0OM,490
|
|
30
30
|
posthog/ai/openai/openai.py,sha256=ts95vdvWH7h0TX4FpLLK_wU_7H0MP3eZBEg0S-lsCKw,20127
|
|
31
|
-
posthog/ai/openai/openai_async.py,sha256=
|
|
32
|
-
posthog/ai/openai/openai_converter.py,sha256=
|
|
31
|
+
posthog/ai/openai/openai_async.py,sha256=Ebd6_H3Zf3wGPycVJd_vOd3ZVoO3Mf3ZV339BExQd6Q,22436
|
|
32
|
+
posthog/ai/openai/openai_converter.py,sha256=_T7Nx5gzGlklbu0iZjj8qaBB4W_IrnSFNUkUOav3TBE,25466
|
|
33
33
|
posthog/ai/openai/openai_providers.py,sha256=zQIFTXHS2-dBKQX7FZxTFo7rIj5iiN7VHm9_2RzuDs8,3941
|
|
34
34
|
posthog/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
35
|
-
posthog/integrations/django.py,sha256=
|
|
35
|
+
posthog/integrations/django.py,sha256=9X37yCF-T-MXUsxqkqjBWG3kdgOCyQYYNJQG_ZlwbRg,12633
|
|
36
36
|
posthog/test/__init__.py,sha256=VYgM6xPbJbvS-xhIcDiBRs0MFC9V_jT65uNeerCz_rM,299
|
|
37
37
|
posthog/test/test_before_send.py,sha256=3546WKlk8rF6bhvqhwcxAsjJovDw0Hf8yTvcYGbrhyI,7912
|
|
38
38
|
posthog/test/test_client.py,sha256=F-jUA0uKgHpVLOdnen2j6WSTp6whlJcpZdSecLoREFg,96273
|
|
@@ -47,8 +47,8 @@ posthog/test/test_request.py,sha256=l19WVyZQc4Iqmh_bpnAFOj4nGRpDK1iO-o5aJDQfFdo,
|
|
|
47
47
|
posthog/test/test_size_limited_dict.py,sha256=Wom7BkzpHmusHilZy0SV3PNzhw7ucuQgqrx86jf8euo,765
|
|
48
48
|
posthog/test/test_types.py,sha256=csLuBiz6RMV36cpg9LVIor4Khq6MfjjGxYXodx5VttY,7586
|
|
49
49
|
posthog/test/test_utils.py,sha256=NUs2bgqrVuMdnKRq52syizgglt5_7wxxZl3dDMun-Tg,9602
|
|
50
|
-
posthog-6.
|
|
51
|
-
posthog-6.
|
|
52
|
-
posthog-6.
|
|
53
|
-
posthog-6.
|
|
54
|
-
posthog-6.
|
|
50
|
+
posthog-6.8.0.dist-info/licenses/LICENSE,sha256=wGf9JBotDkSygFj43m49oiKlFnpMnn97keiZKF-40vE,2450
|
|
51
|
+
posthog-6.8.0.dist-info/METADATA,sha256=MWOz-oeYOBGfqm5bMlyPOg4VDBefAUd0fnvNAUm0ivk,6015
|
|
52
|
+
posthog-6.8.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
53
|
+
posthog-6.8.0.dist-info/top_level.txt,sha256=7FBLsRjIUHVKQsXIhozuI3k-mun1tapp8iZO9EmUPEw,8
|
|
54
|
+
posthog-6.8.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|