Skip to content

Commit 0c61c79

Browse files
committed
feat(llma): send number of web searches
1 parent f719c3d commit 0c61c79

File tree

8 files changed

+426
-2
lines changed

8 files changed

+426
-2
lines changed

posthog/ai/anthropic/anthropic_converter.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,32 @@ def format_anthropic_streaming_content(
163163
return formatted
164164

165165

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 int(getattr(server_tool_use, "web_search_requests", 0))
188+
189+
return 0
190+
191+
166192
def extract_anthropic_usage_from_response(response: Any) -> TokenUsage:
167193
"""
168194
Extract usage from a full Anthropic response (non-streaming).
@@ -191,6 +217,10 @@ def extract_anthropic_usage_from_response(response: Any) -> TokenUsage:
191217
if cache_creation and cache_creation > 0:
192218
result["cache_creation_input_tokens"] = cache_creation
193219

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+
194224
return result
195225

196226

@@ -222,6 +252,16 @@ def extract_anthropic_usage_from_event(event: Any) -> TokenUsage:
222252
if hasattr(event, "usage") and event.usage:
223253
usage["output_tokens"] = getattr(event.usage, "output_tokens", 0)
224254

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+
225265
return usage
226266

227267

posthog/ai/gemini/gemini_converter.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,46 @@ def format_gemini_input(contents: Any) -> List[FormattedMessage]:
338338
return [_format_object_message(contents)]
339339

340340

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, 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+
return 1
363+
364+
# Also check for google_search or grounding in function call names
365+
if hasattr(candidate, "content") and candidate.content:
366+
if hasattr(candidate.content, "parts") and candidate.content.parts:
367+
for part in candidate.content.parts:
368+
if hasattr(part, "function_call") and part.function_call:
369+
function_name = getattr(
370+
part.function_call, "name", ""
371+
).lower()
372+
if (
373+
"google_search" in function_name
374+
or "grounding" in function_name
375+
):
376+
return 1
377+
378+
return 0
379+
380+
341381
def _extract_usage_from_metadata(metadata: Any) -> TokenUsage:
342382
"""
343383
Common logic to extract usage from Gemini metadata.
@@ -382,7 +422,14 @@ def extract_gemini_usage_from_response(response: Any) -> TokenUsage:
382422
if not hasattr(response, "usage_metadata") or not response.usage_metadata:
383423
return TokenUsage(input_tokens=0, output_tokens=0)
384424

385-
return _extract_usage_from_metadata(response.usage_metadata)
425+
usage = _extract_usage_from_metadata(response.usage_metadata)
426+
427+
# Add web search count if present
428+
web_search_count = extract_gemini_web_search_count(response)
429+
if web_search_count > 0:
430+
usage["web_search_count"] = web_search_count
431+
432+
return usage
386433

387434

388435
def extract_gemini_usage_from_chunk(chunk: Any) -> TokenUsage:
@@ -404,6 +451,11 @@ def extract_gemini_usage_from_chunk(chunk: Any) -> TokenUsage:
404451
# Use the shared helper to extract usage
405452
usage = _extract_usage_from_metadata(chunk.usage_metadata)
406453

454+
# Add web search count if present
455+
web_search_count = extract_gemini_web_search_count(chunk)
456+
if web_search_count > 0:
457+
usage["web_search_count"] = web_search_count
458+
407459
return usage
408460

409461

posthog/ai/openai/openai_converter.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,81 @@ def format_openai_streaming_content(
255255
return formatted
256256

257257

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
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+
for item in response.output:
279+
if hasattr(item, "type") and item.type == "web_search_call":
280+
web_search_count += 1
281+
282+
if web_search_count > 0:
283+
return web_search_count
284+
285+
# Priority 2: Binary detection (returns 1 or 0)
286+
287+
# Check root-level indicators (Perplexity)
288+
if hasattr(response, "citations"):
289+
citations = getattr(response, "citations")
290+
if citations and len(citations) > 0:
291+
return 1
292+
293+
if hasattr(response, "search_results"):
294+
search_results = getattr(response, "search_results")
295+
if search_results and len(search_results) > 0:
296+
return 1
297+
298+
if hasattr(response, "usage") and hasattr(response.usage, "search_context_size"):
299+
if response.usage.search_context_size:
300+
return 1
301+
302+
# Check for url_citation annotations in choices (Chat Completions)
303+
if hasattr(response, "choices"):
304+
for choice in response.choices:
305+
if hasattr(choice, "message") and hasattr(choice.message, "annotations"):
306+
annotations = choice.message.annotations
307+
if annotations:
308+
for annotation in annotations:
309+
if (
310+
hasattr(annotation, "type")
311+
and annotation.type == "url_citation"
312+
):
313+
return 1
314+
315+
# Check for url_citation annotations in output (Responses API)
316+
if hasattr(response, "output"):
317+
for item in response.output:
318+
if hasattr(item, "content") and isinstance(item.content, list):
319+
for content_item in item.content:
320+
if hasattr(content_item, "annotations"):
321+
annotations = content_item.annotations
322+
if annotations:
323+
for annotation in annotations:
324+
if (
325+
hasattr(annotation, "type")
326+
and annotation.type == "url_citation"
327+
):
328+
return 1
329+
330+
return 0
331+
332+
258333
def extract_openai_usage_from_response(response: Any) -> TokenUsage:
259334
"""
260335
Extract usage statistics from a full OpenAI response (non-streaming).
@@ -312,6 +387,10 @@ def extract_openai_usage_from_response(response: Any) -> TokenUsage:
312387
if reasoning_tokens > 0:
313388
result["reasoning_tokens"] = reasoning_tokens
314389

390+
web_search_count = extract_openai_web_search_count(response)
391+
if web_search_count > 0:
392+
result["web_search_count"] = web_search_count
393+
315394
return result
316395

317396

@@ -358,6 +437,11 @@ def extract_openai_usage_from_chunk(
358437
chunk.usage.completion_tokens_details.reasoning_tokens
359438
)
360439

440+
# Extract web search count from the chunk (available in final streaming chunks)
441+
web_search_count = extract_openai_web_search_count(chunk)
442+
if web_search_count > 0:
443+
usage["web_search_count"] = web_search_count
444+
361445
elif provider_type == "responses":
362446
# For Responses API, usage is only in chunk.response.usage for completed events
363447
if hasattr(chunk, "type") and chunk.type == "response.completed":
@@ -386,6 +470,12 @@ def extract_openai_usage_from_chunk(
386470
response_usage.output_tokens_details.reasoning_tokens
387471
)
388472

473+
# Extract web search count from the complete response
474+
if hasattr(chunk, "response"):
475+
web_search_count = extract_openai_web_search_count(chunk.response)
476+
if web_search_count > 0:
477+
usage["web_search_count"] = web_search_count
478+
389479
return usage
390480

391481

posthog/ai/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class TokenUsage(TypedDict, total=False):
6363
cache_read_input_tokens: Optional[int]
6464
cache_creation_input_tokens: Optional[int]
6565
reasoning_tokens: Optional[int]
66+
web_search_count: Optional[int]
6667

6768

6869
class ProviderResponse(TypedDict, total=False):

posthog/ai/utils.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ def merge_usage_stats(
5353
if source_reasoning is not None:
5454
current = target.get("reasoning_tokens") or 0
5555
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"] = current + source_web_search
61+
5662
elif mode == "cumulative":
5763
# Replace with latest values (already cumulative)
5864
if source.get("input_tokens") is not None:
@@ -67,6 +73,9 @@ def merge_usage_stats(
6773
]
6874
if source.get("reasoning_tokens") is not None:
6975
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+
7079
else:
7180
raise ValueError(f"Invalid mode: {mode}. Must be 'incremental' or 'cumulative'")
7281

@@ -311,6 +320,10 @@ def call_llm_and_track_usage(
311320
if reasoning is not None and reasoning > 0:
312321
event_properties["$ai_reasoning_tokens"] = reasoning
313322

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+
314327
if posthog_distinct_id is None:
315328
event_properties["$process_person_profile"] = False
316329

@@ -414,6 +427,14 @@ async def call_llm_and_track_usage_async(
414427
if cache_creation is not None and cache_creation > 0:
415428
event_properties["$ai_cache_creation_input_tokens"] = cache_creation
416429

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+
417438
if posthog_distinct_id is None:
418439
event_properties["$process_person_profile"] = False
419440

@@ -535,6 +556,15 @@ def capture_streaming_event(
535556
if value is not None and isinstance(value, int) and value > 0:
536557
event_properties[f"$ai_{field}"] = value
537558

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+
538568
# Handle provider-specific fields
539569
if (
540570
event_data["provider"] == "openai"

posthog/test/ai/anthropic/test_anthropic.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import os
21
from unittest.mock import patch
32

43
import pytest
@@ -1034,3 +1033,47 @@ async def run_test():
10341033
assert props["$ai_output_tokens"] == 25
10351034
assert props["$ai_cache_read_input_tokens"] == 5
10361035
assert props["$ai_cache_creation_input_tokens"] == 0
1036+
1037+
1038+
def test_web_search_count(mock_client):
1039+
"""Test that web search count is properly tracked from Anthropic responses."""
1040+
1041+
# Create a mock usage with web search
1042+
class MockServerToolUse:
1043+
def __init__(self):
1044+
self.web_search_requests = 3
1045+
1046+
class MockUsageWithWebSearch:
1047+
def __init__(self):
1048+
self.input_tokens = 100
1049+
self.output_tokens = 50
1050+
self.cache_read_input_tokens = 0
1051+
self.cache_creation_input_tokens = 0
1052+
self.server_tool_use = MockServerToolUse()
1053+
1054+
class MockResponseWithWebSearch:
1055+
def __init__(self):
1056+
self.content = [MockContent(text="Search results show...")]
1057+
self.model = "claude-3-opus-20240229"
1058+
self.usage = MockUsageWithWebSearch()
1059+
1060+
mock_response = MockResponseWithWebSearch()
1061+
1062+
with patch("anthropic.resources.Messages.create", return_value=mock_response):
1063+
client = Anthropic(api_key="test-key", posthog_client=mock_client)
1064+
response = client.messages.create(
1065+
model="claude-3-opus-20240229",
1066+
messages=[{"role": "user", "content": "Search for recent news"}],
1067+
posthog_distinct_id="test-id",
1068+
)
1069+
1070+
assert response == mock_response
1071+
assert mock_client.capture.call_count == 1
1072+
1073+
call_args = mock_client.capture.call_args[1]
1074+
props = call_args["properties"]
1075+
1076+
# Verify web search count is captured
1077+
assert props["$ai_web_search_count"] == 3
1078+
assert props["$ai_input_tokens"] == 100
1079+
assert props["$ai_output_tokens"] == 50

0 commit comments

Comments
 (0)