@posthog/ai 6.5.0 → 6.6.0

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.
package/dist/index.cjs CHANGED
@@ -26,7 +26,7 @@ function _interopNamespaceDefault(e) {
26
26
 
27
27
  var uuid__namespace = /*#__PURE__*/_interopNamespaceDefault(uuid);
28
28
 
29
- var version = "6.5.0";
29
+ var version = "6.6.0";
30
30
 
31
31
  // Type guards for safer type checking
32
32
  const isString = value => {
@@ -297,6 +297,100 @@ const truncate = input => {
297
297
  }
298
298
  return `${truncatedStr}... [truncated]`;
299
299
  };
300
+ /**
301
+ * Calculate web search count from raw API response.
302
+ *
303
+ * Uses a two-tier detection strategy:
304
+ * Priority 1 (Exact Count): Count actual web search calls when available
305
+ * Priority 2 (Binary Detection): Return 1 if web search indicators are present, 0 otherwise
306
+ *
307
+ * @param result - Raw API response from any provider (OpenAI, Perplexity, OpenRouter, Gemini, etc.)
308
+ * @returns Number of web searches performed (exact count or binary 1/0)
309
+ */
310
+ function calculateWebSearchCount(result) {
311
+ if (!result || typeof result !== 'object') {
312
+ return 0;
313
+ }
314
+ // Priority 1: Exact Count
315
+ // Check for OpenAI Responses API web_search_call items
316
+ if ('output' in result && Array.isArray(result.output)) {
317
+ let count = 0;
318
+ for (const item of result.output) {
319
+ if (typeof item === 'object' && item !== null && 'type' in item && item.type === 'web_search_call') {
320
+ count++;
321
+ }
322
+ }
323
+ if (count > 0) {
324
+ return count;
325
+ }
326
+ }
327
+ // Priority 2: Binary Detection (1 or 0)
328
+ // Check for citations at root level (Perplexity)
329
+ if ('citations' in result && Array.isArray(result.citations) && result.citations.length > 0) {
330
+ return 1;
331
+ }
332
+ // Check for search_results at root level (Perplexity via OpenRouter)
333
+ if ('search_results' in result && Array.isArray(result.search_results) && result.search_results.length > 0) {
334
+ return 1;
335
+ }
336
+ // Check for usage.search_context_size (Perplexity via OpenRouter)
337
+ if ('usage' in result && typeof result.usage === 'object' && result.usage !== null) {
338
+ if ('search_context_size' in result.usage && result.usage.search_context_size) {
339
+ return 1;
340
+ }
341
+ }
342
+ // Check for annotations with url_citation in choices[].message (OpenAI/Perplexity)
343
+ if ('choices' in result && Array.isArray(result.choices)) {
344
+ for (const choice of result.choices) {
345
+ if (typeof choice === 'object' && choice !== null && 'message' in choice) {
346
+ const message = choice.message;
347
+ if (typeof message === 'object' && message !== null && 'annotations' in message) {
348
+ const annotations = message.annotations;
349
+ if (Array.isArray(annotations)) {
350
+ const hasUrlCitation = annotations.some(ann => {
351
+ return typeof ann === 'object' && ann !== null && 'type' in ann && ann.type === 'url_citation';
352
+ });
353
+ if (hasUrlCitation) {
354
+ return 1;
355
+ }
356
+ }
357
+ }
358
+ }
359
+ }
360
+ }
361
+ // Check for annotations in output[].content[] (OpenAI Responses API)
362
+ if ('output' in result && Array.isArray(result.output)) {
363
+ for (const item of result.output) {
364
+ if (typeof item === 'object' && item !== null && 'content' in item) {
365
+ const content = item.content;
366
+ if (Array.isArray(content)) {
367
+ for (const contentItem of content) {
368
+ if (typeof contentItem === 'object' && contentItem !== null && 'annotations' in contentItem) {
369
+ const annotations = contentItem.annotations;
370
+ if (Array.isArray(annotations)) {
371
+ const hasUrlCitation = annotations.some(ann => {
372
+ return typeof ann === 'object' && ann !== null && 'type' in ann && ann.type === 'url_citation';
373
+ });
374
+ if (hasUrlCitation) {
375
+ return 1;
376
+ }
377
+ }
378
+ }
379
+ }
380
+ }
381
+ }
382
+ }
383
+ }
384
+ // Check for grounding_metadata (Gemini)
385
+ if ('candidates' in result && Array.isArray(result.candidates)) {
386
+ for (const candidate of result.candidates) {
387
+ if (typeof candidate === 'object' && candidate !== null && 'grounding_metadata' in candidate && candidate.grounding_metadata) {
388
+ return 1;
389
+ }
390
+ }
391
+ }
392
+ return 0;
393
+ }
300
394
  /**
301
395
  * Extract available tool calls from the request parameters.
302
396
  * These are the tools provided to the LLM, not the tool calls in the response.
@@ -431,6 +525,9 @@ const sendEventToPosthog = async ({
431
525
  } : {}),
432
526
  ...(usage.cacheCreationInputTokens ? {
433
527
  $ai_cache_creation_input_tokens: usage.cacheCreationInputTokens
528
+ } : {}),
529
+ ...(usage.webSearchCount ? {
530
+ $ai_web_search_count: usage.webSearchCount
434
531
  } : {})
435
532
  };
436
533
  const properties = {
@@ -741,12 +838,17 @@ let WrappedCompletions$1 = class WrappedCompletions extends Completions {
741
838
  let accumulatedContent = '';
742
839
  let usage = {
743
840
  inputTokens: 0,
744
- outputTokens: 0
841
+ outputTokens: 0,
842
+ webSearchCount: 0
745
843
  };
746
844
  // Map to track in-progress tool calls
747
845
  const toolCallsInProgress = new Map();
748
846
  for await (const chunk of stream1) {
749
847
  const choice = chunk?.choices?.[0];
848
+ const chunkWebSearchCount = calculateWebSearchCount(chunk);
849
+ if (chunkWebSearchCount > 0 && chunkWebSearchCount > (usage.webSearchCount ?? 0)) {
850
+ usage.webSearchCount = chunkWebSearchCount;
851
+ }
750
852
  // Handle text content
751
853
  const deltaContent = choice?.delta?.content;
752
854
  if (deltaContent) {
@@ -785,6 +887,7 @@ let WrappedCompletions$1 = class WrappedCompletions extends Completions {
785
887
  // Handle usage information
786
888
  if (chunk.usage) {
787
889
  usage = {
890
+ ...usage,
788
891
  inputTokens: chunk.usage.prompt_tokens ?? 0,
789
892
  outputTokens: chunk.usage.completion_tokens ?? 0,
790
893
  reasoningTokens: chunk.usage.completion_tokens_details?.reasoning_tokens ?? 0,
@@ -836,7 +939,13 @@ let WrappedCompletions$1 = class WrappedCompletions extends Completions {
836
939
  baseURL: this.baseURL,
837
940
  params: body,
838
941
  httpStatus: 200,
839
- usage,
942
+ usage: {
943
+ inputTokens: usage.inputTokens,
944
+ outputTokens: usage.outputTokens,
945
+ reasoningTokens: usage.reasoningTokens,
946
+ cacheReadInputTokens: usage.cacheReadInputTokens,
947
+ webSearchCount: usage.webSearchCount
948
+ },
840
949
  tools: availableTools
841
950
  });
842
951
  } catch (error) {
@@ -871,13 +980,14 @@ let WrappedCompletions$1 = class WrappedCompletions extends Completions {
871
980
  if ('choices' in result) {
872
981
  const latency = (Date.now() - startTime) / 1000;
873
982
  const availableTools = extractAvailableToolCalls('openai', openAIParams);
983
+ const formattedOutput = formatResponseOpenAI(result);
874
984
  await sendEventToPosthog({
875
985
  client: this.phClient,
876
986
  ...posthogParams,
877
987
  model: openAIParams.model,
878
988
  provider: 'openai',
879
989
  input: sanitizeOpenAI(openAIParams.messages),
880
- output: formatResponseOpenAI(result),
990
+ output: formattedOutput,
881
991
  latency,
882
992
  baseURL: this.baseURL,
883
993
  params: body,
@@ -886,7 +996,8 @@ let WrappedCompletions$1 = class WrappedCompletions extends Completions {
886
996
  inputTokens: result.usage?.prompt_tokens ?? 0,
887
997
  outputTokens: result.usage?.completion_tokens ?? 0,
888
998
  reasoningTokens: result.usage?.completion_tokens_details?.reasoning_tokens ?? 0,
889
- cacheReadInputTokens: result.usage?.prompt_tokens_details?.cached_tokens ?? 0
999
+ cacheReadInputTokens: result.usage?.prompt_tokens_details?.cached_tokens ?? 0,
1000
+ webSearchCount: calculateWebSearchCount(result)
890
1001
  },
891
1002
  tools: availableTools
892
1003
  });
@@ -941,14 +1052,22 @@ let WrappedResponses$1 = class WrappedResponses extends Responses {
941
1052
  let finalContent = [];
942
1053
  let usage = {
943
1054
  inputTokens: 0,
944
- outputTokens: 0
1055
+ outputTokens: 0,
1056
+ webSearchCount: 0
945
1057
  };
946
1058
  for await (const chunk of stream1) {
1059
+ if ('response' in chunk && chunk.response) {
1060
+ const chunkWebSearchCount = calculateWebSearchCount(chunk.response);
1061
+ if (chunkWebSearchCount > 0 && chunkWebSearchCount > (usage.webSearchCount ?? 0)) {
1062
+ usage.webSearchCount = chunkWebSearchCount;
1063
+ }
1064
+ }
947
1065
  if (chunk.type === 'response.completed' && 'response' in chunk && chunk.response?.output && chunk.response.output.length > 0) {
948
1066
  finalContent = chunk.response.output;
949
1067
  }
950
1068
  if ('response' in chunk && chunk.response?.usage) {
951
1069
  usage = {
1070
+ ...usage,
952
1071
  inputTokens: chunk.response.usage.input_tokens ?? 0,
953
1072
  outputTokens: chunk.response.usage.output_tokens ?? 0,
954
1073
  reasoningTokens: chunk.response.usage.output_tokens_details?.reasoning_tokens ?? 0,
@@ -970,7 +1089,13 @@ let WrappedResponses$1 = class WrappedResponses extends Responses {
970
1089
  baseURL: this.baseURL,
971
1090
  params: body,
972
1091
  httpStatus: 200,
973
- usage,
1092
+ usage: {
1093
+ inputTokens: usage.inputTokens,
1094
+ outputTokens: usage.outputTokens,
1095
+ reasoningTokens: usage.reasoningTokens,
1096
+ cacheReadInputTokens: usage.cacheReadInputTokens,
1097
+ webSearchCount: usage.webSearchCount
1098
+ },
974
1099
  tools: availableTools
975
1100
  });
976
1101
  } catch (error) {
@@ -1005,6 +1130,9 @@ let WrappedResponses$1 = class WrappedResponses extends Responses {
1005
1130
  if ('output' in result) {
1006
1131
  const latency = (Date.now() - startTime) / 1000;
1007
1132
  const availableTools = extractAvailableToolCalls('openai', openAIParams);
1133
+ const formattedOutput = formatResponseOpenAI({
1134
+ output: result.output
1135
+ });
1008
1136
  await sendEventToPosthog({
1009
1137
  client: this.phClient,
1010
1138
  ...posthogParams,
@@ -1012,9 +1140,7 @@ let WrappedResponses$1 = class WrappedResponses extends Responses {
1012
1140
  model: openAIParams.model,
1013
1141
  provider: 'openai',
1014
1142
  input: formatOpenAIResponsesInput(openAIParams.input, openAIParams.instructions),
1015
- output: formatResponseOpenAI({
1016
- output: result.output
1017
- }),
1143
+ output: formattedOutput,
1018
1144
  latency,
1019
1145
  baseURL: this.baseURL,
1020
1146
  params: body,
@@ -1023,7 +1149,8 @@ let WrappedResponses$1 = class WrappedResponses extends Responses {
1023
1149
  inputTokens: result.usage?.input_tokens ?? 0,
1024
1150
  outputTokens: result.usage?.output_tokens ?? 0,
1025
1151
  reasoningTokens: result.usage?.output_tokens_details?.reasoning_tokens ?? 0,
1026
- cacheReadInputTokens: result.usage?.input_tokens_details?.cached_tokens ?? 0
1152
+ cacheReadInputTokens: result.usage?.input_tokens_details?.cached_tokens ?? 0,
1153
+ webSearchCount: calculateWebSearchCount(result)
1027
1154
  },
1028
1155
  tools: availableTools
1029
1156
  });
@@ -1061,9 +1188,9 @@ let WrappedResponses$1 = class WrappedResponses extends Responses {
1061
1188
  } = extractPosthogParams(body);
1062
1189
  const startTime = Date.now();
1063
1190
  const originalCreate = super.create.bind(this);
1064
- const originalSelf = this;
1065
- const tempCreate = originalSelf.create;
1066
- originalSelf.create = originalCreate;
1191
+ const originalSelfRecord = this;
1192
+ const tempCreate = originalSelfRecord['create'];
1193
+ originalSelfRecord['create'] = originalCreate;
1067
1194
  try {
1068
1195
  const parentPromise = super.parse(openAIParams, options);
1069
1196
  const wrappedPromise = parentPromise.then(async result => {
@@ -1112,7 +1239,7 @@ let WrappedResponses$1 = class WrappedResponses extends Responses {
1112
1239
  return wrappedPromise;
1113
1240
  } finally {
1114
1241
  // Restore our wrapped create method
1115
- originalSelf.create = tempCreate;
1242
+ originalSelfRecord['create'] = tempCreate;
1116
1243
  }
1117
1244
  }
1118
1245
  };
@@ -1858,11 +1985,29 @@ const createInstrumentationMiddleware = (phClient, model, options) => {
1858
1985
  cacheCreationInputTokens: providerMetadata.anthropic.cacheCreationInputTokens
1859
1986
  } : {})
1860
1987
  };
1988
+ // Calculate web search count based on provider
1989
+ let webSearchCount = 0;
1990
+ if (providerMetadata?.anthropic && typeof providerMetadata.anthropic === 'object' && 'server_tool_use' in providerMetadata.anthropic) {
1991
+ // Anthropic-specific extraction
1992
+ const serverToolUse = providerMetadata.anthropic.server_tool_use;
1993
+ if (serverToolUse && typeof serverToolUse === 'object' && 'web_search_requests' in serverToolUse && typeof serverToolUse.web_search_requests === 'number') {
1994
+ webSearchCount = serverToolUse.web_search_requests;
1995
+ }
1996
+ } else {
1997
+ // For other providers through Vercel, pass available metadata to helper
1998
+ // Note: Vercel abstracts provider responses, so we may not have access to
1999
+ // raw citations/annotations unless Vercel exposes them in usage/metadata
2000
+ webSearchCount = calculateWebSearchCount({
2001
+ usage: result.usage,
2002
+ providerMetadata: providerMetadata
2003
+ });
2004
+ }
1861
2005
  const usage = {
1862
2006
  inputTokens: result.usage.inputTokens,
1863
2007
  outputTokens: result.usage.outputTokens,
1864
2008
  reasoningTokens: result.usage.reasoningTokens,
1865
2009
  cacheReadInputTokens: result.usage.cachedInputTokens,
2010
+ webSearchCount,
1866
2011
  ...additionalTokenValues
1867
2012
  };
1868
2013
  await sendEventToPosthog({
@@ -1916,6 +2061,7 @@ const createInstrumentationMiddleware = (phClient, model, options) => {
1916
2061
  let generatedText = '';
1917
2062
  let reasoningText = '';
1918
2063
  let usage = {};
2064
+ let providerMetadata = undefined;
1919
2065
  const mergedParams = {
1920
2066
  ...options,
1921
2067
  ...mapVercelParams(params),
@@ -1973,12 +2119,10 @@ const createInstrumentationMiddleware = (phClient, model, options) => {
1973
2119
  });
1974
2120
  }
1975
2121
  if (chunk.type === 'finish') {
1976
- const providerMetadata = chunk.providerMetadata;
1977
- const additionalTokenValues = {
1978
- ...(providerMetadata?.anthropic ? {
1979
- cacheCreationInputTokens: providerMetadata.anthropic.cacheCreationInputTokens
1980
- } : {})
1981
- };
2122
+ providerMetadata = chunk.providerMetadata;
2123
+ const additionalTokenValues = providerMetadata && typeof providerMetadata === 'object' && 'anthropic' in providerMetadata && providerMetadata.anthropic && typeof providerMetadata.anthropic === 'object' && 'cacheCreationInputTokens' in providerMetadata.anthropic ? {
2124
+ cacheCreationInputTokens: providerMetadata.anthropic.cacheCreationInputTokens
2125
+ } : {};
1982
2126
  usage = {
1983
2127
  inputTokens: chunk.usage?.inputTokens,
1984
2128
  outputTokens: chunk.usage?.outputTokens,
@@ -2023,6 +2167,28 @@ const createInstrumentationMiddleware = (phClient, model, options) => {
2023
2167
  role: 'assistant',
2024
2168
  content: content.length === 1 && content[0].type === 'text' ? content[0].text : content
2025
2169
  }] : [];
2170
+ // Calculate web search count based on provider
2171
+ let webSearchCount = 0;
2172
+ if (providerMetadata && typeof providerMetadata === 'object' && 'anthropic' in providerMetadata && providerMetadata.anthropic && typeof providerMetadata.anthropic === 'object' && 'server_tool_use' in providerMetadata.anthropic) {
2173
+ // Anthropic-specific extraction
2174
+ const serverToolUse = providerMetadata.anthropic.server_tool_use;
2175
+ if (serverToolUse && typeof serverToolUse === 'object' && 'web_search_requests' in serverToolUse && typeof serverToolUse.web_search_requests === 'number') {
2176
+ webSearchCount = serverToolUse.web_search_requests;
2177
+ }
2178
+ } else {
2179
+ // For other providers through Vercel, pass available metadata to helper
2180
+ // Note: Vercel abstracts provider responses, so we may not have access to
2181
+ // raw citations/annotations unless Vercel exposes them in usage/metadata
2182
+ webSearchCount = calculateWebSearchCount({
2183
+ usage: usage,
2184
+ providerMetadata: providerMetadata
2185
+ });
2186
+ }
2187
+ // Update usage with web search count
2188
+ const finalUsage = {
2189
+ ...usage,
2190
+ webSearchCount
2191
+ };
2026
2192
  await sendEventToPosthog({
2027
2193
  client: phClient,
2028
2194
  distinctId: options.posthogDistinctId,
@@ -2035,7 +2201,7 @@ const createInstrumentationMiddleware = (phClient, model, options) => {
2035
2201
  baseURL,
2036
2202
  params: mergedParams,
2037
2203
  httpStatus: 200,
2038
- usage,
2204
+ usage: finalUsage,
2039
2205
  tools: availableTools,
2040
2206
  captureImmediate: options.posthogCaptureImmediate
2041
2207
  });
@@ -2121,7 +2287,8 @@ class WrappedMessages extends AnthropicOriginal.Messages {
2121
2287
  inputTokens: 0,
2122
2288
  outputTokens: 0,
2123
2289
  cacheCreationInputTokens: 0,
2124
- cacheReadInputTokens: 0
2290
+ cacheReadInputTokens: 0,
2291
+ webSearchCount: 0
2125
2292
  };
2126
2293
  if ('tee' in value) {
2127
2294
  const [stream1, stream2] = value.tee();
@@ -2198,9 +2365,14 @@ class WrappedMessages extends AnthropicOriginal.Messages {
2198
2365
  usage.inputTokens = chunk.message.usage.input_tokens ?? 0;
2199
2366
  usage.cacheCreationInputTokens = chunk.message.usage.cache_creation_input_tokens ?? 0;
2200
2367
  usage.cacheReadInputTokens = chunk.message.usage.cache_read_input_tokens ?? 0;
2368
+ usage.webSearchCount = chunk.message.usage.server_tool_use?.web_search_requests ?? 0;
2201
2369
  }
2202
2370
  if ('usage' in chunk) {
2203
2371
  usage.outputTokens = chunk.usage.output_tokens ?? 0;
2372
+ // Update web search count if present in delta
2373
+ if (chunk.usage.server_tool_use?.web_search_requests !== undefined) {
2374
+ usage.webSearchCount = chunk.usage.server_tool_use.web_search_requests;
2375
+ }
2204
2376
  }
2205
2377
  }
2206
2378
  const latency = (Date.now() - startTime) / 1000;
@@ -2277,7 +2449,8 @@ class WrappedMessages extends AnthropicOriginal.Messages {
2277
2449
  inputTokens: result.usage.input_tokens ?? 0,
2278
2450
  outputTokens: result.usage.output_tokens ?? 0,
2279
2451
  cacheCreationInputTokens: result.usage.cache_creation_input_tokens ?? 0,
2280
- cacheReadInputTokens: result.usage.cache_read_input_tokens ?? 0
2452
+ cacheReadInputTokens: result.usage.cache_read_input_tokens ?? 0,
2453
+ webSearchCount: result.usage.server_tool_use?.web_search_requests ?? 0
2281
2454
  },
2282
2455
  tools: availableTools
2283
2456
  });
@@ -2351,7 +2524,8 @@ class WrappedModels {
2351
2524
  inputTokens: metadata?.promptTokenCount ?? 0,
2352
2525
  outputTokens: metadata?.candidatesTokenCount ?? 0,
2353
2526
  reasoningTokens: metadata?.thoughtsTokenCount ?? 0,
2354
- cacheReadInputTokens: metadata?.cachedContentTokenCount ?? 0
2527
+ cacheReadInputTokens: metadata?.cachedContentTokenCount ?? 0,
2528
+ webSearchCount: calculateGoogleWebSearchCount(response)
2355
2529
  },
2356
2530
  tools: availableTools
2357
2531
  });
@@ -2388,11 +2562,16 @@ class WrappedModels {
2388
2562
  const accumulatedContent = [];
2389
2563
  let usage = {
2390
2564
  inputTokens: 0,
2391
- outputTokens: 0
2565
+ outputTokens: 0,
2566
+ webSearchCount: 0
2392
2567
  };
2393
2568
  try {
2394
2569
  const stream = await this.client.models.generateContentStream(geminiParams);
2395
2570
  for await (const chunk of stream) {
2571
+ const chunkWebSearchCount = calculateGoogleWebSearchCount(chunk);
2572
+ if (chunkWebSearchCount > 0 && chunkWebSearchCount > (usage.webSearchCount ?? 0)) {
2573
+ usage.webSearchCount = chunkWebSearchCount;
2574
+ }
2396
2575
  // Handle text content
2397
2576
  if (chunk.text) {
2398
2577
  // Find if we already have a text item to append to
@@ -2441,7 +2620,8 @@ class WrappedModels {
2441
2620
  inputTokens: metadata.promptTokenCount ?? 0,
2442
2621
  outputTokens: metadata.candidatesTokenCount ?? 0,
2443
2622
  reasoningTokens: metadata.thoughtsTokenCount ?? 0,
2444
- cacheReadInputTokens: metadata.cachedContentTokenCount ?? 0
2623
+ cacheReadInputTokens: metadata.cachedContentTokenCount ?? 0,
2624
+ webSearchCount: usage.webSearchCount
2445
2625
  };
2446
2626
  }
2447
2627
  yield chunk;
@@ -2464,7 +2644,10 @@ class WrappedModels {
2464
2644
  baseURL: 'https://generativelanguage.googleapis.com',
2465
2645
  params: params,
2466
2646
  httpStatus: 200,
2467
- usage,
2647
+ usage: {
2648
+ ...usage,
2649
+ webSearchCount: usage.webSearchCount
2650
+ },
2468
2651
  tools: availableTools
2469
2652
  });
2470
2653
  } catch (error) {
@@ -2607,6 +2790,57 @@ class WrappedModels {
2607
2790
  return messages;
2608
2791
  }
2609
2792
  }
2793
+ /**
2794
+ * Detect if Google Search grounding was used in the response.
2795
+ * Gemini bills per request that uses grounding, not per individual query.
2796
+ * Returns 1 if grounding was used, 0 otherwise.
2797
+ */
2798
+ function calculateGoogleWebSearchCount(response) {
2799
+ if (!response || typeof response !== 'object' || !('candidates' in response)) {
2800
+ return 0;
2801
+ }
2802
+ const candidates = response.candidates;
2803
+ if (!Array.isArray(candidates)) {
2804
+ return 0;
2805
+ }
2806
+ const hasGrounding = candidates.some(candidate => {
2807
+ if (!candidate || typeof candidate !== 'object') {
2808
+ return false;
2809
+ }
2810
+ // Check for grounding metadata
2811
+ if ('groundingMetadata' in candidate && candidate.groundingMetadata) {
2812
+ const metadata = candidate.groundingMetadata;
2813
+ if (typeof metadata === 'object') {
2814
+ // Check if web_search_queries exists and is non-empty
2815
+ if ('webSearchQueries' in metadata && Array.isArray(metadata.webSearchQueries) && metadata.webSearchQueries.length > 0) {
2816
+ return true;
2817
+ }
2818
+ // Check if grounding_chunks exists and is non-empty
2819
+ if ('groundingChunks' in metadata && Array.isArray(metadata.groundingChunks) && metadata.groundingChunks.length > 0) {
2820
+ return true;
2821
+ }
2822
+ }
2823
+ }
2824
+ // Check for google search in function calls
2825
+ if ('content' in candidate && candidate.content && typeof candidate.content === 'object') {
2826
+ const content = candidate.content;
2827
+ if ('parts' in content && Array.isArray(content.parts)) {
2828
+ return content.parts.some(part => {
2829
+ if (!part || typeof part !== 'object' || !('functionCall' in part)) {
2830
+ return false;
2831
+ }
2832
+ const functionCall = part.functionCall;
2833
+ if (functionCall && typeof functionCall === 'object' && 'name' in functionCall && typeof functionCall.name === 'string') {
2834
+ return functionCall.name.includes('google_search') || functionCall.name.includes('grounding');
2835
+ }
2836
+ return false;
2837
+ });
2838
+ }
2839
+ }
2840
+ return false;
2841
+ });
2842
+ return hasGrounding ? 1 : 0;
2843
+ }
2610
2844
 
2611
2845
  function getDefaultExportFromCjs (x) {
2612
2846
  return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
@@ -3427,6 +3661,9 @@ class LangChainCallbackHandler extends BaseCallbackHandler {
3427
3661
  if (additionalTokenData.reasoningTokens) {
3428
3662
  eventProperties['$ai_reasoning_tokens'] = additionalTokenData.reasoningTokens;
3429
3663
  }
3664
+ if (additionalTokenData.webSearchCount !== undefined) {
3665
+ eventProperties['$ai_web_search_count'] = additionalTokenData.webSearchCount;
3666
+ }
3430
3667
  // Handle generations/completions
3431
3668
  let completions;
3432
3669
  if (output.generations && Array.isArray(output.generations)) {
@@ -3592,6 +3829,47 @@ class LangChainCallbackHandler extends BaseCallbackHandler {
3592
3829
  } else if (usage.reasoningTokens != null) {
3593
3830
  additionalTokenData.reasoningTokens = usage.reasoningTokens;
3594
3831
  }
3832
+ // Extract web search counts from various provider formats
3833
+ let webSearchCount;
3834
+ // Priority 1: Exact Count
3835
+ // Check Anthropic format (server_tool_use.web_search_requests)
3836
+ if (usage.server_tool_use?.web_search_requests !== undefined) {
3837
+ webSearchCount = usage.server_tool_use.web_search_requests;
3838
+ }
3839
+ // Priority 2: Binary Detection (1 or 0)
3840
+ // Check for citations array (Perplexity)
3841
+ else if (usage.citations && Array.isArray(usage.citations) && usage.citations.length > 0) {
3842
+ webSearchCount = 1;
3843
+ }
3844
+ // Check for search_results array (Perplexity via OpenRouter)
3845
+ else if (usage.search_results && Array.isArray(usage.search_results) && usage.search_results.length > 0) {
3846
+ webSearchCount = 1;
3847
+ }
3848
+ // Check for search_context_size (Perplexity via OpenRouter)
3849
+ else if (usage.search_context_size) {
3850
+ webSearchCount = 1;
3851
+ }
3852
+ // Check for annotations with url_citation type
3853
+ else if (usage.annotations && Array.isArray(usage.annotations)) {
3854
+ const hasUrlCitation = usage.annotations.some(ann => {
3855
+ return ann && typeof ann === 'object' && 'type' in ann && ann.type === 'url_citation';
3856
+ });
3857
+ if (hasUrlCitation) {
3858
+ webSearchCount = 1;
3859
+ }
3860
+ }
3861
+ // Check Gemini format (grounding metadata - binary 0 or 1)
3862
+ else if (usage.grounding_metadata?.grounding_support !== undefined || usage.grounding_metadata?.web_search_queries !== undefined) {
3863
+ webSearchCount = 1;
3864
+ }
3865
+ if (webSearchCount !== undefined) {
3866
+ additionalTokenData.webSearchCount = webSearchCount;
3867
+ }
3868
+ // In LangChain, input_tokens is the sum of input and cache read tokens.
3869
+ // Our cost calculation expects them to be separate, for Anthropic.
3870
+ if (parsedUsage.input && additionalTokenData.cacheReadInputTokens) {
3871
+ parsedUsage.input = Math.max(parsedUsage.input - additionalTokenData.cacheReadInputTokens, 0);
3872
+ }
3595
3873
  return [parsedUsage.input, parsedUsage.output, additionalTokenData];
3596
3874
  }
3597
3875
  parseUsage(response) {