@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.d.ts CHANGED
@@ -54,7 +54,7 @@ interface MonitoringOpenAIConfig$1 extends ClientOptions$1 {
54
54
  posthog: PostHog;
55
55
  baseURL?: string;
56
56
  }
57
- type RequestOptions$2 = Record<string, any>;
57
+ type RequestOptions$2 = Record<string, unknown>;
58
58
  declare class PostHogOpenAI extends OpenAI {
59
59
  private readonly phClient;
60
60
  chat: WrappedChat$1;
package/dist/index.mjs CHANGED
@@ -6,7 +6,7 @@ import { wrapLanguageModel } from 'ai';
6
6
  import AnthropicOriginal from '@anthropic-ai/sdk';
7
7
  import { GoogleGenAI } from '@google/genai';
8
8
 
9
- var version = "6.5.0";
9
+ var version = "6.6.0";
10
10
 
11
11
  // Type guards for safer type checking
12
12
  const isString = value => {
@@ -277,6 +277,100 @@ const truncate = input => {
277
277
  }
278
278
  return `${truncatedStr}... [truncated]`;
279
279
  };
280
+ /**
281
+ * Calculate web search count from raw API response.
282
+ *
283
+ * Uses a two-tier detection strategy:
284
+ * Priority 1 (Exact Count): Count actual web search calls when available
285
+ * Priority 2 (Binary Detection): Return 1 if web search indicators are present, 0 otherwise
286
+ *
287
+ * @param result - Raw API response from any provider (OpenAI, Perplexity, OpenRouter, Gemini, etc.)
288
+ * @returns Number of web searches performed (exact count or binary 1/0)
289
+ */
290
+ function calculateWebSearchCount(result) {
291
+ if (!result || typeof result !== 'object') {
292
+ return 0;
293
+ }
294
+ // Priority 1: Exact Count
295
+ // Check for OpenAI Responses API web_search_call items
296
+ if ('output' in result && Array.isArray(result.output)) {
297
+ let count = 0;
298
+ for (const item of result.output) {
299
+ if (typeof item === 'object' && item !== null && 'type' in item && item.type === 'web_search_call') {
300
+ count++;
301
+ }
302
+ }
303
+ if (count > 0) {
304
+ return count;
305
+ }
306
+ }
307
+ // Priority 2: Binary Detection (1 or 0)
308
+ // Check for citations at root level (Perplexity)
309
+ if ('citations' in result && Array.isArray(result.citations) && result.citations.length > 0) {
310
+ return 1;
311
+ }
312
+ // Check for search_results at root level (Perplexity via OpenRouter)
313
+ if ('search_results' in result && Array.isArray(result.search_results) && result.search_results.length > 0) {
314
+ return 1;
315
+ }
316
+ // Check for usage.search_context_size (Perplexity via OpenRouter)
317
+ if ('usage' in result && typeof result.usage === 'object' && result.usage !== null) {
318
+ if ('search_context_size' in result.usage && result.usage.search_context_size) {
319
+ return 1;
320
+ }
321
+ }
322
+ // Check for annotations with url_citation in choices[].message (OpenAI/Perplexity)
323
+ if ('choices' in result && Array.isArray(result.choices)) {
324
+ for (const choice of result.choices) {
325
+ if (typeof choice === 'object' && choice !== null && 'message' in choice) {
326
+ const message = choice.message;
327
+ if (typeof message === 'object' && message !== null && 'annotations' in message) {
328
+ const annotations = message.annotations;
329
+ if (Array.isArray(annotations)) {
330
+ const hasUrlCitation = annotations.some(ann => {
331
+ return typeof ann === 'object' && ann !== null && 'type' in ann && ann.type === 'url_citation';
332
+ });
333
+ if (hasUrlCitation) {
334
+ return 1;
335
+ }
336
+ }
337
+ }
338
+ }
339
+ }
340
+ }
341
+ // Check for annotations in output[].content[] (OpenAI Responses API)
342
+ if ('output' in result && Array.isArray(result.output)) {
343
+ for (const item of result.output) {
344
+ if (typeof item === 'object' && item !== null && 'content' in item) {
345
+ const content = item.content;
346
+ if (Array.isArray(content)) {
347
+ for (const contentItem of content) {
348
+ if (typeof contentItem === 'object' && contentItem !== null && 'annotations' in contentItem) {
349
+ const annotations = contentItem.annotations;
350
+ if (Array.isArray(annotations)) {
351
+ const hasUrlCitation = annotations.some(ann => {
352
+ return typeof ann === 'object' && ann !== null && 'type' in ann && ann.type === 'url_citation';
353
+ });
354
+ if (hasUrlCitation) {
355
+ return 1;
356
+ }
357
+ }
358
+ }
359
+ }
360
+ }
361
+ }
362
+ }
363
+ }
364
+ // Check for grounding_metadata (Gemini)
365
+ if ('candidates' in result && Array.isArray(result.candidates)) {
366
+ for (const candidate of result.candidates) {
367
+ if (typeof candidate === 'object' && candidate !== null && 'grounding_metadata' in candidate && candidate.grounding_metadata) {
368
+ return 1;
369
+ }
370
+ }
371
+ }
372
+ return 0;
373
+ }
280
374
  /**
281
375
  * Extract available tool calls from the request parameters.
282
376
  * These are the tools provided to the LLM, not the tool calls in the response.
@@ -411,6 +505,9 @@ const sendEventToPosthog = async ({
411
505
  } : {}),
412
506
  ...(usage.cacheCreationInputTokens ? {
413
507
  $ai_cache_creation_input_tokens: usage.cacheCreationInputTokens
508
+ } : {}),
509
+ ...(usage.webSearchCount ? {
510
+ $ai_web_search_count: usage.webSearchCount
414
511
  } : {})
415
512
  };
416
513
  const properties = {
@@ -721,12 +818,17 @@ let WrappedCompletions$1 = class WrappedCompletions extends Completions {
721
818
  let accumulatedContent = '';
722
819
  let usage = {
723
820
  inputTokens: 0,
724
- outputTokens: 0
821
+ outputTokens: 0,
822
+ webSearchCount: 0
725
823
  };
726
824
  // Map to track in-progress tool calls
727
825
  const toolCallsInProgress = new Map();
728
826
  for await (const chunk of stream1) {
729
827
  const choice = chunk?.choices?.[0];
828
+ const chunkWebSearchCount = calculateWebSearchCount(chunk);
829
+ if (chunkWebSearchCount > 0 && chunkWebSearchCount > (usage.webSearchCount ?? 0)) {
830
+ usage.webSearchCount = chunkWebSearchCount;
831
+ }
730
832
  // Handle text content
731
833
  const deltaContent = choice?.delta?.content;
732
834
  if (deltaContent) {
@@ -765,6 +867,7 @@ let WrappedCompletions$1 = class WrappedCompletions extends Completions {
765
867
  // Handle usage information
766
868
  if (chunk.usage) {
767
869
  usage = {
870
+ ...usage,
768
871
  inputTokens: chunk.usage.prompt_tokens ?? 0,
769
872
  outputTokens: chunk.usage.completion_tokens ?? 0,
770
873
  reasoningTokens: chunk.usage.completion_tokens_details?.reasoning_tokens ?? 0,
@@ -816,7 +919,13 @@ let WrappedCompletions$1 = class WrappedCompletions extends Completions {
816
919
  baseURL: this.baseURL,
817
920
  params: body,
818
921
  httpStatus: 200,
819
- usage,
922
+ usage: {
923
+ inputTokens: usage.inputTokens,
924
+ outputTokens: usage.outputTokens,
925
+ reasoningTokens: usage.reasoningTokens,
926
+ cacheReadInputTokens: usage.cacheReadInputTokens,
927
+ webSearchCount: usage.webSearchCount
928
+ },
820
929
  tools: availableTools
821
930
  });
822
931
  } catch (error) {
@@ -851,13 +960,14 @@ let WrappedCompletions$1 = class WrappedCompletions extends Completions {
851
960
  if ('choices' in result) {
852
961
  const latency = (Date.now() - startTime) / 1000;
853
962
  const availableTools = extractAvailableToolCalls('openai', openAIParams);
963
+ const formattedOutput = formatResponseOpenAI(result);
854
964
  await sendEventToPosthog({
855
965
  client: this.phClient,
856
966
  ...posthogParams,
857
967
  model: openAIParams.model,
858
968
  provider: 'openai',
859
969
  input: sanitizeOpenAI(openAIParams.messages),
860
- output: formatResponseOpenAI(result),
970
+ output: formattedOutput,
861
971
  latency,
862
972
  baseURL: this.baseURL,
863
973
  params: body,
@@ -866,7 +976,8 @@ let WrappedCompletions$1 = class WrappedCompletions extends Completions {
866
976
  inputTokens: result.usage?.prompt_tokens ?? 0,
867
977
  outputTokens: result.usage?.completion_tokens ?? 0,
868
978
  reasoningTokens: result.usage?.completion_tokens_details?.reasoning_tokens ?? 0,
869
- cacheReadInputTokens: result.usage?.prompt_tokens_details?.cached_tokens ?? 0
979
+ cacheReadInputTokens: result.usage?.prompt_tokens_details?.cached_tokens ?? 0,
980
+ webSearchCount: calculateWebSearchCount(result)
870
981
  },
871
982
  tools: availableTools
872
983
  });
@@ -921,14 +1032,22 @@ let WrappedResponses$1 = class WrappedResponses extends Responses {
921
1032
  let finalContent = [];
922
1033
  let usage = {
923
1034
  inputTokens: 0,
924
- outputTokens: 0
1035
+ outputTokens: 0,
1036
+ webSearchCount: 0
925
1037
  };
926
1038
  for await (const chunk of stream1) {
1039
+ if ('response' in chunk && chunk.response) {
1040
+ const chunkWebSearchCount = calculateWebSearchCount(chunk.response);
1041
+ if (chunkWebSearchCount > 0 && chunkWebSearchCount > (usage.webSearchCount ?? 0)) {
1042
+ usage.webSearchCount = chunkWebSearchCount;
1043
+ }
1044
+ }
927
1045
  if (chunk.type === 'response.completed' && 'response' in chunk && chunk.response?.output && chunk.response.output.length > 0) {
928
1046
  finalContent = chunk.response.output;
929
1047
  }
930
1048
  if ('response' in chunk && chunk.response?.usage) {
931
1049
  usage = {
1050
+ ...usage,
932
1051
  inputTokens: chunk.response.usage.input_tokens ?? 0,
933
1052
  outputTokens: chunk.response.usage.output_tokens ?? 0,
934
1053
  reasoningTokens: chunk.response.usage.output_tokens_details?.reasoning_tokens ?? 0,
@@ -950,7 +1069,13 @@ let WrappedResponses$1 = class WrappedResponses extends Responses {
950
1069
  baseURL: this.baseURL,
951
1070
  params: body,
952
1071
  httpStatus: 200,
953
- usage,
1072
+ usage: {
1073
+ inputTokens: usage.inputTokens,
1074
+ outputTokens: usage.outputTokens,
1075
+ reasoningTokens: usage.reasoningTokens,
1076
+ cacheReadInputTokens: usage.cacheReadInputTokens,
1077
+ webSearchCount: usage.webSearchCount
1078
+ },
954
1079
  tools: availableTools
955
1080
  });
956
1081
  } catch (error) {
@@ -985,6 +1110,9 @@ let WrappedResponses$1 = class WrappedResponses extends Responses {
985
1110
  if ('output' in result) {
986
1111
  const latency = (Date.now() - startTime) / 1000;
987
1112
  const availableTools = extractAvailableToolCalls('openai', openAIParams);
1113
+ const formattedOutput = formatResponseOpenAI({
1114
+ output: result.output
1115
+ });
988
1116
  await sendEventToPosthog({
989
1117
  client: this.phClient,
990
1118
  ...posthogParams,
@@ -992,9 +1120,7 @@ let WrappedResponses$1 = class WrappedResponses extends Responses {
992
1120
  model: openAIParams.model,
993
1121
  provider: 'openai',
994
1122
  input: formatOpenAIResponsesInput(openAIParams.input, openAIParams.instructions),
995
- output: formatResponseOpenAI({
996
- output: result.output
997
- }),
1123
+ output: formattedOutput,
998
1124
  latency,
999
1125
  baseURL: this.baseURL,
1000
1126
  params: body,
@@ -1003,7 +1129,8 @@ let WrappedResponses$1 = class WrappedResponses extends Responses {
1003
1129
  inputTokens: result.usage?.input_tokens ?? 0,
1004
1130
  outputTokens: result.usage?.output_tokens ?? 0,
1005
1131
  reasoningTokens: result.usage?.output_tokens_details?.reasoning_tokens ?? 0,
1006
- cacheReadInputTokens: result.usage?.input_tokens_details?.cached_tokens ?? 0
1132
+ cacheReadInputTokens: result.usage?.input_tokens_details?.cached_tokens ?? 0,
1133
+ webSearchCount: calculateWebSearchCount(result)
1007
1134
  },
1008
1135
  tools: availableTools
1009
1136
  });
@@ -1041,9 +1168,9 @@ let WrappedResponses$1 = class WrappedResponses extends Responses {
1041
1168
  } = extractPosthogParams(body);
1042
1169
  const startTime = Date.now();
1043
1170
  const originalCreate = super.create.bind(this);
1044
- const originalSelf = this;
1045
- const tempCreate = originalSelf.create;
1046
- originalSelf.create = originalCreate;
1171
+ const originalSelfRecord = this;
1172
+ const tempCreate = originalSelfRecord['create'];
1173
+ originalSelfRecord['create'] = originalCreate;
1047
1174
  try {
1048
1175
  const parentPromise = super.parse(openAIParams, options);
1049
1176
  const wrappedPromise = parentPromise.then(async result => {
@@ -1092,7 +1219,7 @@ let WrappedResponses$1 = class WrappedResponses extends Responses {
1092
1219
  return wrappedPromise;
1093
1220
  } finally {
1094
1221
  // Restore our wrapped create method
1095
- originalSelf.create = tempCreate;
1222
+ originalSelfRecord['create'] = tempCreate;
1096
1223
  }
1097
1224
  }
1098
1225
  };
@@ -1838,11 +1965,29 @@ const createInstrumentationMiddleware = (phClient, model, options) => {
1838
1965
  cacheCreationInputTokens: providerMetadata.anthropic.cacheCreationInputTokens
1839
1966
  } : {})
1840
1967
  };
1968
+ // Calculate web search count based on provider
1969
+ let webSearchCount = 0;
1970
+ if (providerMetadata?.anthropic && typeof providerMetadata.anthropic === 'object' && 'server_tool_use' in providerMetadata.anthropic) {
1971
+ // Anthropic-specific extraction
1972
+ const serverToolUse = providerMetadata.anthropic.server_tool_use;
1973
+ if (serverToolUse && typeof serverToolUse === 'object' && 'web_search_requests' in serverToolUse && typeof serverToolUse.web_search_requests === 'number') {
1974
+ webSearchCount = serverToolUse.web_search_requests;
1975
+ }
1976
+ } else {
1977
+ // For other providers through Vercel, pass available metadata to helper
1978
+ // Note: Vercel abstracts provider responses, so we may not have access to
1979
+ // raw citations/annotations unless Vercel exposes them in usage/metadata
1980
+ webSearchCount = calculateWebSearchCount({
1981
+ usage: result.usage,
1982
+ providerMetadata: providerMetadata
1983
+ });
1984
+ }
1841
1985
  const usage = {
1842
1986
  inputTokens: result.usage.inputTokens,
1843
1987
  outputTokens: result.usage.outputTokens,
1844
1988
  reasoningTokens: result.usage.reasoningTokens,
1845
1989
  cacheReadInputTokens: result.usage.cachedInputTokens,
1990
+ webSearchCount,
1846
1991
  ...additionalTokenValues
1847
1992
  };
1848
1993
  await sendEventToPosthog({
@@ -1896,6 +2041,7 @@ const createInstrumentationMiddleware = (phClient, model, options) => {
1896
2041
  let generatedText = '';
1897
2042
  let reasoningText = '';
1898
2043
  let usage = {};
2044
+ let providerMetadata = undefined;
1899
2045
  const mergedParams = {
1900
2046
  ...options,
1901
2047
  ...mapVercelParams(params),
@@ -1953,12 +2099,10 @@ const createInstrumentationMiddleware = (phClient, model, options) => {
1953
2099
  });
1954
2100
  }
1955
2101
  if (chunk.type === 'finish') {
1956
- const providerMetadata = chunk.providerMetadata;
1957
- const additionalTokenValues = {
1958
- ...(providerMetadata?.anthropic ? {
1959
- cacheCreationInputTokens: providerMetadata.anthropic.cacheCreationInputTokens
1960
- } : {})
1961
- };
2102
+ providerMetadata = chunk.providerMetadata;
2103
+ const additionalTokenValues = providerMetadata && typeof providerMetadata === 'object' && 'anthropic' in providerMetadata && providerMetadata.anthropic && typeof providerMetadata.anthropic === 'object' && 'cacheCreationInputTokens' in providerMetadata.anthropic ? {
2104
+ cacheCreationInputTokens: providerMetadata.anthropic.cacheCreationInputTokens
2105
+ } : {};
1962
2106
  usage = {
1963
2107
  inputTokens: chunk.usage?.inputTokens,
1964
2108
  outputTokens: chunk.usage?.outputTokens,
@@ -2003,6 +2147,28 @@ const createInstrumentationMiddleware = (phClient, model, options) => {
2003
2147
  role: 'assistant',
2004
2148
  content: content.length === 1 && content[0].type === 'text' ? content[0].text : content
2005
2149
  }] : [];
2150
+ // Calculate web search count based on provider
2151
+ let webSearchCount = 0;
2152
+ if (providerMetadata && typeof providerMetadata === 'object' && 'anthropic' in providerMetadata && providerMetadata.anthropic && typeof providerMetadata.anthropic === 'object' && 'server_tool_use' in providerMetadata.anthropic) {
2153
+ // Anthropic-specific extraction
2154
+ const serverToolUse = providerMetadata.anthropic.server_tool_use;
2155
+ if (serverToolUse && typeof serverToolUse === 'object' && 'web_search_requests' in serverToolUse && typeof serverToolUse.web_search_requests === 'number') {
2156
+ webSearchCount = serverToolUse.web_search_requests;
2157
+ }
2158
+ } else {
2159
+ // For other providers through Vercel, pass available metadata to helper
2160
+ // Note: Vercel abstracts provider responses, so we may not have access to
2161
+ // raw citations/annotations unless Vercel exposes them in usage/metadata
2162
+ webSearchCount = calculateWebSearchCount({
2163
+ usage: usage,
2164
+ providerMetadata: providerMetadata
2165
+ });
2166
+ }
2167
+ // Update usage with web search count
2168
+ const finalUsage = {
2169
+ ...usage,
2170
+ webSearchCount
2171
+ };
2006
2172
  await sendEventToPosthog({
2007
2173
  client: phClient,
2008
2174
  distinctId: options.posthogDistinctId,
@@ -2015,7 +2181,7 @@ const createInstrumentationMiddleware = (phClient, model, options) => {
2015
2181
  baseURL,
2016
2182
  params: mergedParams,
2017
2183
  httpStatus: 200,
2018
- usage,
2184
+ usage: finalUsage,
2019
2185
  tools: availableTools,
2020
2186
  captureImmediate: options.posthogCaptureImmediate
2021
2187
  });
@@ -2101,7 +2267,8 @@ class WrappedMessages extends AnthropicOriginal.Messages {
2101
2267
  inputTokens: 0,
2102
2268
  outputTokens: 0,
2103
2269
  cacheCreationInputTokens: 0,
2104
- cacheReadInputTokens: 0
2270
+ cacheReadInputTokens: 0,
2271
+ webSearchCount: 0
2105
2272
  };
2106
2273
  if ('tee' in value) {
2107
2274
  const [stream1, stream2] = value.tee();
@@ -2178,9 +2345,14 @@ class WrappedMessages extends AnthropicOriginal.Messages {
2178
2345
  usage.inputTokens = chunk.message.usage.input_tokens ?? 0;
2179
2346
  usage.cacheCreationInputTokens = chunk.message.usage.cache_creation_input_tokens ?? 0;
2180
2347
  usage.cacheReadInputTokens = chunk.message.usage.cache_read_input_tokens ?? 0;
2348
+ usage.webSearchCount = chunk.message.usage.server_tool_use?.web_search_requests ?? 0;
2181
2349
  }
2182
2350
  if ('usage' in chunk) {
2183
2351
  usage.outputTokens = chunk.usage.output_tokens ?? 0;
2352
+ // Update web search count if present in delta
2353
+ if (chunk.usage.server_tool_use?.web_search_requests !== undefined) {
2354
+ usage.webSearchCount = chunk.usage.server_tool_use.web_search_requests;
2355
+ }
2184
2356
  }
2185
2357
  }
2186
2358
  const latency = (Date.now() - startTime) / 1000;
@@ -2257,7 +2429,8 @@ class WrappedMessages extends AnthropicOriginal.Messages {
2257
2429
  inputTokens: result.usage.input_tokens ?? 0,
2258
2430
  outputTokens: result.usage.output_tokens ?? 0,
2259
2431
  cacheCreationInputTokens: result.usage.cache_creation_input_tokens ?? 0,
2260
- cacheReadInputTokens: result.usage.cache_read_input_tokens ?? 0
2432
+ cacheReadInputTokens: result.usage.cache_read_input_tokens ?? 0,
2433
+ webSearchCount: result.usage.server_tool_use?.web_search_requests ?? 0
2261
2434
  },
2262
2435
  tools: availableTools
2263
2436
  });
@@ -2331,7 +2504,8 @@ class WrappedModels {
2331
2504
  inputTokens: metadata?.promptTokenCount ?? 0,
2332
2505
  outputTokens: metadata?.candidatesTokenCount ?? 0,
2333
2506
  reasoningTokens: metadata?.thoughtsTokenCount ?? 0,
2334
- cacheReadInputTokens: metadata?.cachedContentTokenCount ?? 0
2507
+ cacheReadInputTokens: metadata?.cachedContentTokenCount ?? 0,
2508
+ webSearchCount: calculateGoogleWebSearchCount(response)
2335
2509
  },
2336
2510
  tools: availableTools
2337
2511
  });
@@ -2368,11 +2542,16 @@ class WrappedModels {
2368
2542
  const accumulatedContent = [];
2369
2543
  let usage = {
2370
2544
  inputTokens: 0,
2371
- outputTokens: 0
2545
+ outputTokens: 0,
2546
+ webSearchCount: 0
2372
2547
  };
2373
2548
  try {
2374
2549
  const stream = await this.client.models.generateContentStream(geminiParams);
2375
2550
  for await (const chunk of stream) {
2551
+ const chunkWebSearchCount = calculateGoogleWebSearchCount(chunk);
2552
+ if (chunkWebSearchCount > 0 && chunkWebSearchCount > (usage.webSearchCount ?? 0)) {
2553
+ usage.webSearchCount = chunkWebSearchCount;
2554
+ }
2376
2555
  // Handle text content
2377
2556
  if (chunk.text) {
2378
2557
  // Find if we already have a text item to append to
@@ -2421,7 +2600,8 @@ class WrappedModels {
2421
2600
  inputTokens: metadata.promptTokenCount ?? 0,
2422
2601
  outputTokens: metadata.candidatesTokenCount ?? 0,
2423
2602
  reasoningTokens: metadata.thoughtsTokenCount ?? 0,
2424
- cacheReadInputTokens: metadata.cachedContentTokenCount ?? 0
2603
+ cacheReadInputTokens: metadata.cachedContentTokenCount ?? 0,
2604
+ webSearchCount: usage.webSearchCount
2425
2605
  };
2426
2606
  }
2427
2607
  yield chunk;
@@ -2444,7 +2624,10 @@ class WrappedModels {
2444
2624
  baseURL: 'https://generativelanguage.googleapis.com',
2445
2625
  params: params,
2446
2626
  httpStatus: 200,
2447
- usage,
2627
+ usage: {
2628
+ ...usage,
2629
+ webSearchCount: usage.webSearchCount
2630
+ },
2448
2631
  tools: availableTools
2449
2632
  });
2450
2633
  } catch (error) {
@@ -2587,6 +2770,57 @@ class WrappedModels {
2587
2770
  return messages;
2588
2771
  }
2589
2772
  }
2773
+ /**
2774
+ * Detect if Google Search grounding was used in the response.
2775
+ * Gemini bills per request that uses grounding, not per individual query.
2776
+ * Returns 1 if grounding was used, 0 otherwise.
2777
+ */
2778
+ function calculateGoogleWebSearchCount(response) {
2779
+ if (!response || typeof response !== 'object' || !('candidates' in response)) {
2780
+ return 0;
2781
+ }
2782
+ const candidates = response.candidates;
2783
+ if (!Array.isArray(candidates)) {
2784
+ return 0;
2785
+ }
2786
+ const hasGrounding = candidates.some(candidate => {
2787
+ if (!candidate || typeof candidate !== 'object') {
2788
+ return false;
2789
+ }
2790
+ // Check for grounding metadata
2791
+ if ('groundingMetadata' in candidate && candidate.groundingMetadata) {
2792
+ const metadata = candidate.groundingMetadata;
2793
+ if (typeof metadata === 'object') {
2794
+ // Check if web_search_queries exists and is non-empty
2795
+ if ('webSearchQueries' in metadata && Array.isArray(metadata.webSearchQueries) && metadata.webSearchQueries.length > 0) {
2796
+ return true;
2797
+ }
2798
+ // Check if grounding_chunks exists and is non-empty
2799
+ if ('groundingChunks' in metadata && Array.isArray(metadata.groundingChunks) && metadata.groundingChunks.length > 0) {
2800
+ return true;
2801
+ }
2802
+ }
2803
+ }
2804
+ // Check for google search in function calls
2805
+ if ('content' in candidate && candidate.content && typeof candidate.content === 'object') {
2806
+ const content = candidate.content;
2807
+ if ('parts' in content && Array.isArray(content.parts)) {
2808
+ return content.parts.some(part => {
2809
+ if (!part || typeof part !== 'object' || !('functionCall' in part)) {
2810
+ return false;
2811
+ }
2812
+ const functionCall = part.functionCall;
2813
+ if (functionCall && typeof functionCall === 'object' && 'name' in functionCall && typeof functionCall.name === 'string') {
2814
+ return functionCall.name.includes('google_search') || functionCall.name.includes('grounding');
2815
+ }
2816
+ return false;
2817
+ });
2818
+ }
2819
+ }
2820
+ return false;
2821
+ });
2822
+ return hasGrounding ? 1 : 0;
2823
+ }
2590
2824
 
2591
2825
  function getDefaultExportFromCjs (x) {
2592
2826
  return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
@@ -3407,6 +3641,9 @@ class LangChainCallbackHandler extends BaseCallbackHandler {
3407
3641
  if (additionalTokenData.reasoningTokens) {
3408
3642
  eventProperties['$ai_reasoning_tokens'] = additionalTokenData.reasoningTokens;
3409
3643
  }
3644
+ if (additionalTokenData.webSearchCount !== undefined) {
3645
+ eventProperties['$ai_web_search_count'] = additionalTokenData.webSearchCount;
3646
+ }
3410
3647
  // Handle generations/completions
3411
3648
  let completions;
3412
3649
  if (output.generations && Array.isArray(output.generations)) {
@@ -3572,6 +3809,47 @@ class LangChainCallbackHandler extends BaseCallbackHandler {
3572
3809
  } else if (usage.reasoningTokens != null) {
3573
3810
  additionalTokenData.reasoningTokens = usage.reasoningTokens;
3574
3811
  }
3812
+ // Extract web search counts from various provider formats
3813
+ let webSearchCount;
3814
+ // Priority 1: Exact Count
3815
+ // Check Anthropic format (server_tool_use.web_search_requests)
3816
+ if (usage.server_tool_use?.web_search_requests !== undefined) {
3817
+ webSearchCount = usage.server_tool_use.web_search_requests;
3818
+ }
3819
+ // Priority 2: Binary Detection (1 or 0)
3820
+ // Check for citations array (Perplexity)
3821
+ else if (usage.citations && Array.isArray(usage.citations) && usage.citations.length > 0) {
3822
+ webSearchCount = 1;
3823
+ }
3824
+ // Check for search_results array (Perplexity via OpenRouter)
3825
+ else if (usage.search_results && Array.isArray(usage.search_results) && usage.search_results.length > 0) {
3826
+ webSearchCount = 1;
3827
+ }
3828
+ // Check for search_context_size (Perplexity via OpenRouter)
3829
+ else if (usage.search_context_size) {
3830
+ webSearchCount = 1;
3831
+ }
3832
+ // Check for annotations with url_citation type
3833
+ else if (usage.annotations && Array.isArray(usage.annotations)) {
3834
+ const hasUrlCitation = usage.annotations.some(ann => {
3835
+ return ann && typeof ann === 'object' && 'type' in ann && ann.type === 'url_citation';
3836
+ });
3837
+ if (hasUrlCitation) {
3838
+ webSearchCount = 1;
3839
+ }
3840
+ }
3841
+ // Check Gemini format (grounding metadata - binary 0 or 1)
3842
+ else if (usage.grounding_metadata?.grounding_support !== undefined || usage.grounding_metadata?.web_search_queries !== undefined) {
3843
+ webSearchCount = 1;
3844
+ }
3845
+ if (webSearchCount !== undefined) {
3846
+ additionalTokenData.webSearchCount = webSearchCount;
3847
+ }
3848
+ // In LangChain, input_tokens is the sum of input and cache read tokens.
3849
+ // Our cost calculation expects them to be separate, for Anthropic.
3850
+ if (parsedUsage.input && additionalTokenData.cacheReadInputTokens) {
3851
+ parsedUsage.input = Math.max(parsedUsage.input - additionalTokenData.cacheReadInputTokens, 0);
3852
+ }
3575
3853
  return [parsedUsage.input, parsedUsage.output, additionalTokenData];
3576
3854
  }
3577
3855
  parseUsage(response) {