@posthog/ai
Version:
PostHog Node.js AI integrations
1,232 lines (1,174 loc) • 44.5 kB
JavaScript
'use strict';
var uuid = require('uuid');
var core = require('@posthog/core');
// Type guards for safer type checking
const isString = value => {
return typeof value === 'string';
};
const DATA_URL_PREFIX_RE = /^data:([^;,\s]+)(?:;[^;,\s]+)*;base64,/i;
const BASE64_ALPHABET_RE = /^[A-Za-z0-9+/_=-]+$/;
class Base64Recognizer {
recognize(value, minLength) {
const dataUrl = DATA_URL_PREFIX_RE.exec(value);
if (dataUrl) return {
kind: 'data-url',
mediaType: dataUrl[1]
};
if (value.length < minLength) return {
kind: 'none'
};
const confidencePrefix = value.slice(0, minLength);
if (BASE64_ALPHABET_RE.test(confidencePrefix)) {
return {
kind: 'raw'
};
} else {
return {
kind: 'none'
};
}
}
}
const MIME_HINT_KEYS = ['mediaType', 'media_type', 'mimeType', 'mime_type'];
const STRONG_CONTEXT_KEYS = new Set(['data', 'file_data', 'fileData', 'image_url', 'imageUrl', 'video_url', 'videoUrl', 'audio', 'audio_data', 'audioData', 'inline_data', 'inlineData', 'source', 'result']);
const STRONG_CONTEXT_TYPES = new Set(['image', 'image_url', 'input_image', 'audio', 'input_audio', 'video', 'video_url', 'file', 'input_file', 'document', 'media', 'file-data']);
const FILE_FAMILY_TYPES = new Set(['file', 'input_file', 'document', 'media', 'file-data']);
const KNOWN_AUDIO_FORMATS = new Set(['wav', 'mp3', 'ogg', 'flac', 'm4a', 'aac', 'webm']);
class MediaTypeContext {
static EMPTY = new MediaTypeContext(undefined, undefined);
constructor(parent, key) {
this.parent = parent;
this.key = key;
}
inferMediaType() {
return this.inferFromSiblingMime() ?? this.inferFromSiblingFormat() ?? this.inferFromParentType() ?? this.inferFromKey();
}
inferFromSiblingMime() {
if (!this.parent) return undefined;
for (const hint of MIME_HINT_KEYS) {
const v = this.parent[hint];
if (typeof v === 'string') return v;
}
return undefined;
}
inferFromSiblingFormat() {
if (!this.parent) return undefined;
const fmt = this.parent.format;
if (typeof fmt === 'string' && KNOWN_AUDIO_FORMATS.has(fmt.toLowerCase())) {
return `audio/${fmt.toLowerCase()}`;
}
return undefined;
}
inferFromParentType() {
if (!this.parent) return undefined;
const t = this.parent.type;
if (typeof t !== 'string') return undefined;
if (t === 'image' || t === 'image_url' || t === 'input_image') return 'image';
if (t === 'audio' || t === 'input_audio') return 'audio';
if (t === 'video' || t === 'video_url') return 'video';
if (FILE_FAMILY_TYPES.has(t)) return 'application/octet-stream';
return undefined;
}
inferFromKey() {
if (!this.key) return undefined;
const key = this.key.toLowerCase();
if (key.includes('audio')) return 'audio';
if (key.includes('video')) return 'video';
if (key.includes('image')) return 'image';
if (key.includes('file') || key.includes('document')) return 'application/octet-stream';
return undefined;
}
signalsBinary() {
if (this.parent) {
for (const hint of MIME_HINT_KEYS) {
if (typeof this.parent[hint] === 'string') return true;
}
const fmt = this.parent.format;
if (typeof fmt === 'string' && KNOWN_AUDIO_FORMATS.has(fmt.toLowerCase())) return true;
const t = this.parent.type;
if (typeof t === 'string' && STRONG_CONTEXT_TYPES.has(t)) return true;
}
if (this.key && STRONG_CONTEXT_KEYS.has(this.key)) return true;
return false;
}
}
const STRONG_CONTEXT_MIN_LENGTH = 64;
const WEAK_CONTEXT_MIN_LENGTH = 1024;
class BinaryContentRedactor {
visited = new WeakSet();
constructor(recognizer = new Base64Recognizer()) {
this.recognizer = recognizer;
}
redact(value) {
if (this.isMultimodalEnabled()) return value;
this.visited = new WeakSet();
return this.walk(value, MediaTypeContext.EMPTY);
}
walk(value, ctx) {
if (value === null || value === undefined) return value;
if (typeof value === 'string') return this.redactString(value, ctx);
if (typeof value !== 'object') return value;
// Buffer extends Uint8Array, so this branch catches both.
if (typeof Uint8Array !== 'undefined' && value instanceof Uint8Array) {
return this.placeholderFor(ctx.inferMediaType());
}
if (this.visited.has(value)) return null;
this.visited.add(value);
if (Array.isArray(value)) {
return value.map(item => this.walk(item, ctx));
}
const obj = value;
const out = {};
for (const k of Object.keys(obj)) {
out[k] = this.walk(obj[k], new MediaTypeContext(obj, k));
}
return out;
}
redactString(value, ctx) {
const minLength = ctx.signalsBinary() ? STRONG_CONTEXT_MIN_LENGTH : WEAK_CONTEXT_MIN_LENGTH;
const recognition = this.recognizer.recognize(value, minLength);
switch (recognition.kind) {
case 'data-url':
return this.placeholderFor(recognition.mediaType);
case 'raw':
return this.placeholderFor(ctx.inferMediaType());
case 'none':
return value;
}
}
placeholderFor(mediaType) {
if (!mediaType) return '[base64 redacted]';
if (mediaType === 'application/octet-stream') return '[base64 file redacted]';
return `[base64 ${mediaType} redacted]`;
}
isMultimodalEnabled() {
const val = process.env._INTERNAL_LLMA_MULTIMODAL || '';
return val.toLowerCase() === 'true' || val === '1' || val.toLowerCase() === 'yes';
}
}
const redactor = new BinaryContentRedactor();
function redactBase64DataUrl(str) {
return redactor.redact(str);
}
const TOKEN_PROPERTY_KEYS = new Set(['$ai_input_tokens', '$ai_output_tokens', '$ai_cache_read_input_tokens', '$ai_cache_creation_input_tokens', '$ai_total_tokens', '$ai_reasoning_tokens']);
function getTokensSource(posthogProperties) {
if (posthogProperties && Object.keys(posthogProperties).some(key => TOKEN_PROPERTY_KEYS.has(key))) {
return 'passthrough';
}
return 'sdk';
}
// limit large outputs by truncating to 200kb (approx 200k bytes)
const MAX_OUTPUT_SIZE = 200000;
const STRING_FORMAT = 'utf8';
// Reused across calls to avoid per-invocation allocation; truncate() runs
// hundreds of times for prompts with many parts.
const sharedTextEncoder = new TextEncoder();
const sharedTextDecoder = new TextDecoder(STRING_FORMAT, {
fatal: false
});
const utf8ByteLength = str => sharedTextEncoder.encode(str).byteLength;
/**
* Safely converts content to a string, preserving structure for objects/arrays.
* - If content is already a string, returns it as-is
* - If content is an object or array, stringifies it with JSON.stringify to preserve structure
* - Otherwise, converts to string with String()
*
* This prevents the "[object Object]" bug when objects are naively converted to strings.
*
* @param content - The content to convert to a string
* @returns A string representation that preserves structure for complex types
*/
function toContentString(content) {
if (typeof content === 'string') {
return content;
}
if (content !== undefined && content !== null && typeof content === 'object') {
try {
return JSON.stringify(content);
} catch {
// Fallback for circular refs, BigInt, or objects with throwing toJSON
return String(content);
}
}
return String(content);
}
const getModelParams = params => {
if (!params) {
return {};
}
const modelParams = {};
const paramKeys = ['temperature', 'max_tokens', 'max_completion_tokens', 'top_p', 'frequency_penalty', 'presence_penalty', 'n', 'stop', 'stream', 'streaming', 'language', 'response_format', 'timestamp_granularities'];
for (const key of paramKeys) {
if (key in params && params[key] !== undefined) {
modelParams[key] = params[key];
}
}
return modelParams;
};
const withPrivacyMode = (client, privacyMode, input) => {
return client.privacy_mode || privacyMode ? null : input;
};
function toSafeString(input) {
if (input === undefined || input === null) {
return '';
}
if (typeof input === 'string') {
return input;
}
try {
return JSON.stringify(input);
} catch {
console.warn('Failed to stringify input', input);
return '';
}
}
const truncate = input => {
const str = toSafeString(input);
if (str === '') {
return '';
}
// Check if we need to truncate and ensure STRING_FORMAT is respected
const buffer = sharedTextEncoder.encode(str);
if (buffer.length <= MAX_OUTPUT_SIZE) {
// Ensure STRING_FORMAT is respected
return sharedTextDecoder.decode(buffer);
}
// Truncate the buffer and ensure a valid string is returned.
// fatal: false means we get U+FFFD at the end if truncation broke the encoding.
const truncatedBuffer = buffer.slice(0, MAX_OUTPUT_SIZE);
let truncatedStr = sharedTextDecoder.decode(truncatedBuffer);
if (truncatedStr.endsWith('\uFFFD')) {
truncatedStr = truncatedStr.slice(0, -1);
}
return `${truncatedStr}... [truncated]`;
};
/**
* Calculate web search count from raw API response.
*
* Uses a two-tier detection strategy:
* Priority 1 (Exact Count): Count actual web search calls when available
* Priority 2 (Binary Detection): Return 1 if web search indicators are present, 0 otherwise
*
* @param result - Raw API response from any provider (OpenAI, Perplexity, OpenRouter, Gemini, etc.)
* @returns Number of web searches performed (exact count or binary 1/0)
*/
function calculateWebSearchCount(result) {
if (!result || typeof result !== 'object') {
return 0;
}
// Priority 1: Exact Count
// Check for OpenAI Responses API web_search_call items
if ('output' in result && Array.isArray(result.output)) {
let count = 0;
for (const item of result.output) {
if (typeof item === 'object' && item !== null && 'type' in item && item.type === 'web_search_call') {
count++;
}
}
if (count > 0) {
return count;
}
}
// Priority 2: Binary Detection (1 or 0)
// Check for citations at root level (Perplexity)
if ('citations' in result && Array.isArray(result.citations) && result.citations.length > 0) {
return 1;
}
// Check for search_results at root level (Perplexity via OpenRouter)
if ('search_results' in result && Array.isArray(result.search_results) && result.search_results.length > 0) {
return 1;
}
// Check for usage.search_context_size (Perplexity via OpenRouter)
if ('usage' in result && typeof result.usage === 'object' && result.usage !== null) {
if ('search_context_size' in result.usage && result.usage.search_context_size) {
return 1;
}
}
// Check for annotations with url_citation in choices[].message or choices[].delta (OpenAI/Perplexity)
if ('choices' in result && Array.isArray(result.choices)) {
for (const choice of result.choices) {
if (typeof choice === 'object' && choice !== null) {
// Check both message (non-streaming) and delta (streaming) for annotations
const content = ('message' in choice ? choice.message : null) || ('delta' in choice ? choice.delta : null);
if (typeof content === 'object' && content !== null && 'annotations' in content) {
const annotations = content.annotations;
if (Array.isArray(annotations)) {
const hasUrlCitation = annotations.some(ann => {
return typeof ann === 'object' && ann !== null && 'type' in ann && ann.type === 'url_citation';
});
if (hasUrlCitation) {
return 1;
}
}
}
}
}
}
// Check for annotations in output[].content[] (OpenAI Responses API)
if ('output' in result && Array.isArray(result.output)) {
for (const item of result.output) {
if (typeof item === 'object' && item !== null && 'content' in item) {
const content = item.content;
if (Array.isArray(content)) {
for (const contentItem of content) {
if (typeof contentItem === 'object' && contentItem !== null && 'annotations' in contentItem) {
const annotations = contentItem.annotations;
if (Array.isArray(annotations)) {
const hasUrlCitation = annotations.some(ann => {
return typeof ann === 'object' && ann !== null && 'type' in ann && ann.type === 'url_citation';
});
if (hasUrlCitation) {
return 1;
}
}
}
}
}
}
}
}
// Check for grounding_metadata (Gemini)
if ('candidates' in result && Array.isArray(result.candidates)) {
for (const candidate of result.candidates) {
if (typeof candidate === 'object' && candidate !== null && 'grounding_metadata' in candidate && candidate.grounding_metadata) {
return 1;
}
}
}
return 0;
}
/**
* Extract available tool calls from the request parameters.
* These are the tools provided to the LLM, not the tool calls in the response.
*/
const extractAvailableToolCalls = (provider, params) => {
{
if (params.tools) {
return params.tools;
}
return null;
}
};
let AIEvent = /*#__PURE__*/function (AIEvent) {
AIEvent["Generation"] = "$ai_generation";
AIEvent["Embedding"] = "$ai_embedding";
return AIEvent;
}({});
function sanitizeValues(obj) {
if (obj === undefined || obj === null) {
return obj;
}
const jsonSafe = JSON.parse(JSON.stringify(obj));
if (typeof jsonSafe === 'string') {
// Sanitize lone surrogates by round-tripping through UTF-8
return new TextDecoder().decode(new TextEncoder().encode(jsonSafe));
} else if (Array.isArray(jsonSafe)) {
return jsonSafe.map(sanitizeValues);
} else if (jsonSafe && typeof jsonSafe === 'object') {
return Object.fromEntries(Object.entries(jsonSafe).map(([k, v]) => [k, sanitizeValues(v)]));
}
return jsonSafe;
}
var version = "7.19.5";
const DEFAULT_MAX_DEPTH = 3;
const MAX_STACK_LINES = 20;
function serializeError(value, depth = DEFAULT_MAX_DEPTH) {
if (depth < 0 || value === null || typeof value !== 'object') {
return value;
}
if (value instanceof Error) {
const out = {
name: value.name,
message: value.message,
stack: truncateStack(value.stack)
};
for (const key of Object.keys(value)) {
out[key] = serializeError(value[key], depth - 1);
}
if (value.cause !== undefined) {
out.cause = serializeError(value.cause, depth - 1);
}
return out;
}
if (Array.isArray(value)) {
return value.map(item => serializeError(item, depth - 1));
}
return value;
}
function stringifyError(error) {
try {
return JSON.stringify(sanitizeValues(serializeError(error)));
} catch {
if (error instanceof Error) {
return JSON.stringify({
name: error.name,
message: error.message
});
}
return JSON.stringify({
message: String(error)
});
}
}
function truncateStack(stack) {
if (!stack) {
return stack;
}
const lines = stack.split('\n');
if (lines.length <= MAX_STACK_LINES) {
return stack;
}
return [...lines.slice(0, MAX_STACK_LINES), '... (truncated)'].join('\n');
}
/**
* Options for `captureAiGeneration`. Mirrors the `$ai_generation` event shape
* directly so that any caller — first-party SDK wrappers and external code
* alike — produces an identical event.
*/
/**
* Capture an `$ai_generation` (or `$ai_embedding`) event to PostHog.
*
* This is the canonical primitive that every `@posthog/ai` wrapper
* (`withTracing`, `OpenAI`, `Anthropic`, `GoogleGenAI`, …) funnels through, so
* external code can use it directly to instrument LLM calls made through
* arbitrary clients (Cloudflare Workers AI, custom HTTP, etc.) and get the
* same events the SDK wrappers produce.
*
* When `error` is set, the event is captured as an error. If the error is an
* object, it is mutated in place to set `__posthog_previously_captured_error`
* so callers can re-throw the original error reference safely.
*/
const captureAiGeneration = async (client, options) => {
if (!client.capture) {
return;
}
const traceId = options.traceId ?? uuid.v4();
const eventType = options.eventType ?? AIEvent.Generation;
const privacyMode = options.privacyMode ?? false;
const usage = options.usage ?? {};
const safeInput = sanitizeValues(options.input);
const safeOutput = sanitizeValues(options.output);
let httpStatus = options.httpStatus;
let errorData = {};
if (options.error) {
if (httpStatus === undefined) {
if (typeof options.error === 'object' && 'status' in options.error && typeof options.error.status === 'number') {
httpStatus = options.error.status;
} else {
httpStatus = 500;
}
}
let exceptionId;
if (client.options?.enableExceptionAutocapture) {
exceptionId = core.uuidv7();
client.captureException(options.error, undefined, {
$ai_trace_id: traceId
}, exceptionId);
if (typeof options.error === 'object') {
options.error.__posthog_previously_captured_error = true;
}
}
errorData = {
$ai_is_error: true,
$ai_error: stringifyError(options.error),
$exception_event_id: exceptionId
};
}
httpStatus = httpStatus ?? 200;
let costOverrideData = {};
if (options.costOverride) {
const inputCostUSD = (options.costOverride.inputCost ?? 0) * (usage.inputTokens ?? 0);
const outputCostUSD = (options.costOverride.outputCost ?? 0) * (usage.outputTokens ?? 0);
costOverrideData = {
$ai_input_cost_usd: inputCostUSD,
$ai_output_cost_usd: outputCostUSD,
$ai_total_cost_usd: inputCostUSD + outputCostUSD
};
}
const additionalTokenValues = {
...(usage.reasoningTokens ? {
$ai_reasoning_tokens: usage.reasoningTokens
} : {}),
...(usage.cacheReadInputTokens ? {
$ai_cache_read_input_tokens: usage.cacheReadInputTokens
} : {}),
...(usage.cacheCreationInputTokens ? {
$ai_cache_creation_input_tokens: usage.cacheCreationInputTokens
} : {}),
...(usage.webSearchCount ? {
$ai_web_search_count: usage.webSearchCount
} : {}),
...(usage.rawUsage ? {
$ai_usage: usage.rawUsage
} : {})
};
const properties = {
$ai_lib: 'posthog-ai',
$ai_lib_version: version,
$ai_provider: options.providerOverride ?? options.provider,
$ai_model: options.modelOverride ?? options.model,
$ai_model_parameters: options.modelParameters ?? {},
$ai_input: withPrivacyMode(client, privacyMode, safeInput),
$ai_output_choices: withPrivacyMode(client, privacyMode, safeOutput),
$ai_http_status: httpStatus,
$ai_input_tokens: usage.inputTokens ?? 0,
...(usage.outputTokens !== undefined ? {
$ai_output_tokens: usage.outputTokens
} : {}),
...additionalTokenValues,
$ai_latency: options.latency ?? 0,
...(options.timeToFirstToken !== undefined ? {
$ai_time_to_first_token: options.timeToFirstToken
} : {}),
$ai_trace_id: traceId,
$ai_base_url: options.baseURL ?? '',
...options.properties,
$ai_tokens_source: getTokensSource(options.properties),
...(options.distinctId ? {} : {
$process_person_profile: false
}),
...(options.stopReason ? {
$ai_stop_reason: options.stopReason
} : {}),
...(options.tools ? {
$ai_tools: options.tools
} : {}),
...errorData,
...costOverrideData
};
const event = {
distinctId: options.distinctId ?? traceId,
event: eventType,
properties,
groups: options.groups
};
if (options.captureImmediate) {
await client.captureImmediate(event);
} else {
client.capture(event);
}
};
// Union types for dual version support
// Type guards
function isV3Model(model) {
return model.specificationVersion === 'v3';
}
// Content types for the output array
const mapVercelParams = params => {
return {
temperature: params.temperature,
max_output_tokens: params.maxOutputTokens,
top_p: params.topP,
frequency_penalty: params.frequencyPenalty,
presence_penalty: params.presencePenalty,
stop: params.stopSequences,
stream: params.stream
};
};
const mapVercelPrompt = messages => {
// Map and truncate individual content
const inputs = messages.map(message => {
let content;
// Handle system role which has string content
if (message.role === 'system') {
content = [{
type: 'text',
text: truncate(toContentString(message.content))
}];
} else {
// Handle other roles which have array content
if (Array.isArray(message.content)) {
content = message.content.map(c => {
if (c.type === 'text') {
return {
type: 'text',
text: truncate(c.text)
};
} else if (c.type === 'file') {
// For file type, check if it's a data URL and redact if needed
let fileData;
const contentData = c.data;
if (contentData instanceof URL) {
fileData = contentData.toString();
} else if (isString(contentData)) {
// Redact base64 data URLs and raw base64 to prevent oversized events
fileData = redactBase64DataUrl(contentData);
} else {
fileData = 'raw files not supported';
}
return {
type: 'file',
file: fileData,
mediaType: c.mediaType
};
} else if (c.type === 'reasoning') {
return {
type: 'reasoning',
text: truncate(c.reasoning)
};
} else if (c.type === 'tool-call') {
return {
type: 'tool-call',
toolCallId: c.toolCallId,
toolName: c.toolName,
input: c.input
};
} else if (c.type === 'tool-result') {
return {
type: 'tool-result',
toolCallId: c.toolCallId,
toolName: c.toolName,
output: c.output,
isError: c.isError
};
}
return {
type: 'text',
text: ''
};
});
} else {
// Fallback for non-array content
content = [{
type: 'text',
text: truncate(toContentString(message.content))
}];
}
}
return {
role: message.role,
content
};
});
try {
// Trim the inputs array until its serialized JSON size fits within MAX_OUTPUT_SIZE.
// Pre-compute each message's byte size once so we can shift by accumulated budget
// in a single linear pass, instead of re-stringifying the whole array per iteration.
const messageSizes = inputs.map(m => utf8ByteLength(JSON.stringify(m)));
// Account for the surrounding `[` `]` plus a comma between each pair of elements.
let totalBytes = 2 + Math.max(0, messageSizes.length - 1);
for (const size of messageSizes) {
totalBytes += size;
}
let removedCount = 0;
while (totalBytes > MAX_OUTPUT_SIZE && removedCount < messageSizes.length) {
totalBytes -= messageSizes[removedCount];
// Each removed message past the first also drops the comma that joined it.
if (removedCount < messageSizes.length - 1) {
totalBytes -= 1;
}
removedCount++;
}
if (removedCount > 0) {
inputs.splice(0, removedCount);
// Add one placeholder to indicate how many were removed
inputs.unshift({
role: 'posthog',
content: `[${removedCount} message${removedCount === 1 ? '' : 's'} removed due to size limit]`
});
}
} catch (error) {
console.error('Error stringifying inputs', error);
return [{
role: 'posthog',
content: 'An error occurred while processing your request. Please try again.'
}];
}
return inputs;
};
const mapVercelOutput = result => {
const content = result.map(item => {
if (item.type === 'text') {
return {
type: 'text',
text: truncate(item.text)
};
}
if (item.type === 'tool-call') {
const toolCall = item;
const rawArgs = toolCall.input ?? toolCall.args ?? toolCall.arguments ?? {};
return {
type: 'tool-call',
id: item.toolCallId,
function: {
name: item.toolName,
arguments: typeof rawArgs === 'string' ? rawArgs : JSON.stringify(rawArgs)
}
};
}
if (item.type === 'reasoning') {
return {
type: 'reasoning',
text: truncate(item.text)
};
}
if (item.type === 'file') {
// Handle files similar to input mapping - avoid large base64 data
let fileData;
if (item.data instanceof URL) {
fileData = item.data.toString();
} else if (typeof item.data === 'string') {
fileData = redactBase64DataUrl(item.data);
// If not redacted and still large, replace with size indicator
if (fileData === item.data && item.data.length > 1000) {
fileData = `[${item.mediaType} file - ${item.data.length} bytes]`;
}
} else {
fileData = `[binary ${item.mediaType} file]`;
}
return {
type: 'file',
name: 'generated_file',
mediaType: item.mediaType,
data: fileData
};
}
if (item.type === 'source') {
return {
type: 'source',
sourceType: item.sourceType,
id: item.id,
url: item.url || '',
title: item.title || ''
};
}
// Fallback for unknown types - try to extract text if possible
return {
type: 'text',
text: truncate(JSON.stringify(item))
};
});
if (content.length > 0) {
return [{
role: 'assistant',
content: content.length === 1 && content[0].type === 'text' ? content[0].text : content
}];
}
// otherwise stringify and truncate
try {
const jsonOutput = JSON.stringify(result);
return [{
content: truncate(jsonOutput),
role: 'assistant'
}];
} catch {
console.error('Error stringifying output');
return [];
}
};
const extractProvider = model => {
const provider = model.provider.toLowerCase();
const providerName = provider.split('.')[0];
return providerName;
};
// Extract web search count from provider metadata (works for both V2 and V3)
const extractWebSearchCount = (providerMetadata, usage) => {
// Try Anthropic-specific extraction
if (providerMetadata && typeof providerMetadata === 'object' && 'anthropic' in providerMetadata && providerMetadata.anthropic && typeof providerMetadata.anthropic === 'object' && 'server_tool_use' in providerMetadata.anthropic) {
const serverToolUse = providerMetadata.anthropic.server_tool_use;
if (serverToolUse && typeof serverToolUse === 'object' && 'web_search_requests' in serverToolUse && typeof serverToolUse.web_search_requests === 'number') {
return serverToolUse.web_search_requests;
}
}
// Fall back to generic calculation
return calculateWebSearchCount({
usage,
providerMetadata
});
};
// Helper to extract numeric token value from V2 (number) or V3 (object with .total) usage formats
const extractTokenCount = value => {
if (typeof value === 'number') {
return value;
}
if (value && typeof value === 'object' && 'total' in value && typeof value.total === 'number') {
return value.total;
}
return undefined;
};
// Helper to extract reasoning tokens from V2 (usage.reasoningTokens) or V3 (usage.outputTokens.reasoning)
const extractReasoningTokens = usage => {
// V2 style: top-level reasoningTokens
if ('reasoningTokens' in usage) {
return usage.reasoningTokens;
}
// V3 style: nested in outputTokens.reasoning
if ('outputTokens' in usage && usage.outputTokens && typeof usage.outputTokens === 'object' && 'reasoning' in usage.outputTokens) {
return usage.outputTokens.reasoning;
}
return undefined;
};
// Helper to extract cached input tokens from V2 (usage.cachedInputTokens) or V3 (usage.inputTokens.cacheRead)
const extractCacheReadTokens = usage => {
// V2 style: top-level cachedInputTokens
if ('cachedInputTokens' in usage) {
return usage.cachedInputTokens;
}
// V3 style: nested in inputTokens.cacheRead
if ('inputTokens' in usage && usage.inputTokens && typeof usage.inputTokens === 'object' && 'cacheRead' in usage.inputTokens) {
return usage.inputTokens.cacheRead;
}
return undefined;
};
// Helper to extract cache write tokens from V3 (usage.inputTokens.cacheWrite). Providers like
// Amazon Bedrock populate this standardized field instead of providerMetadata.anthropic.
const extractCacheWriteTokens = usage => {
if ('inputTokens' in usage && usage.inputTokens && typeof usage.inputTokens === 'object' && 'cacheWrite' in usage.inputTokens) {
return usage.inputTokens.cacheWrite;
}
return undefined;
};
// Extract additional token values from provider metadata, with a V3 standardized fallback
// (e.g. Amazon Bedrock exposes cache write tokens via usage.inputTokens.cacheWrite rather
// than providerMetadata.anthropic.cacheCreationInputTokens). A cacheWrite of 0 is treated
// as absent so we preserve the pre-fallback event shape on providers that simply omit the
// field — consumers downstream saw `$ai_cache_creation_input_tokens` missing, not 0.
const extractAdditionalTokenValues = (providerMetadata, usage) => {
if (providerMetadata && typeof providerMetadata === 'object' && 'anthropic' in providerMetadata && providerMetadata.anthropic && typeof providerMetadata.anthropic === 'object' && 'cacheCreationInputTokens' in providerMetadata.anthropic) {
return {
cacheCreationInputTokens: providerMetadata.anthropic.cacheCreationInputTokens
};
}
if (usage && typeof usage === 'object') {
const cacheWrite = extractCacheWriteTokens(usage);
if (typeof cacheWrite === 'number' && cacheWrite > 0) {
return {
cacheCreationInputTokens: cacheWrite
};
}
}
return {};
};
// Detects Anthropic Claude regardless of host (direct Anthropic, Amazon Bedrock, Google Vertex, etc.).
// The server applies exclusive cache token accounting based on the model name, so any Claude model
// needs its V3 input tokens adjusted to exclude cache tokens — not just those routed through a
// provider whose name contains "anthropic". Accepts the resolved modelId string (not the raw model)
// so it sees the same id the server does after posthogModelOverride / response.modelId fallbacks.
const isAnthropicClaudeModel = (modelId, provider) => {
if (provider.toLowerCase().includes('anthropic')) {
return true;
}
return /claude|anthropic/i.test(modelId);
};
// For Anthropic providers in V3, inputTokens.total is the sum of all tokens (uncached + cache read + cache write).
// Our cost calculation expects inputTokens to be only the uncached portion for Anthropic.
// This helper subtracts cache tokens from inputTokens for Anthropic V3 models.
const adjustAnthropicV3CacheTokens = (model, modelId, provider, usage) => {
if (isV3Model(model) && isAnthropicClaudeModel(modelId, provider)) {
const cacheReadTokens = usage.cacheReadInputTokens || 0;
const cacheWriteTokens = usage.cacheCreationInputTokens || 0;
const cacheTokens = cacheReadTokens + cacheWriteTokens;
if (usage.inputTokens && cacheTokens > 0) {
usage.inputTokens = Math.max(usage.inputTokens - cacheTokens, 0);
}
}
};
/**
* Wraps a Vercel AI SDK language model (V2 or V3) with PostHog tracing.
* Automatically detects the model version and applies appropriate instrumentation.
*/
const wrapVercelLanguageModel = (model, phClient, options) => {
const traceId = options.posthogTraceId ?? uuid.v4();
const mergedOptions = {
...options,
posthogTraceId: traceId,
posthogDistinctId: options.posthogDistinctId,
posthogProperties: {
...options.posthogProperties,
$ai_framework: 'vercel',
$ai_framework_version: model.specificationVersion === 'v3' ? '6' : '5'
}
};
// Shared `captureAiGeneration` options for every call site in this wrapper.
const baseOptions = {
distinctId: mergedOptions.posthogDistinctId,
traceId,
properties: mergedOptions.posthogProperties,
groups: mergedOptions.posthogGroups,
privacyMode: mergedOptions.posthogPrivacyMode,
modelOverride: mergedOptions.posthogModelOverride,
providerOverride: mergedOptions.posthogProviderOverride,
costOverride: mergedOptions.posthogCostOverride,
captureImmediate: mergedOptions.posthogCaptureImmediate
};
// Create wrapped model using Object.create to preserve the prototype chain
// This automatically inherits all properties (including getters) from the model
const wrappedModel = Object.create(model, {
doGenerate: {
value: async params => {
const startTime = Date.now();
const mergedParams = {
...mergedOptions,
...mapVercelParams(params)
};
const availableTools = extractAvailableToolCalls('vercel', params);
try {
const result = await model.doGenerate(params);
const modelId = mergedOptions.posthogModelOverride ?? (result.response?.modelId ? result.response.modelId : model.modelId);
const provider = mergedOptions.posthogProviderOverride ?? extractProvider(model);
const baseURL = ''; // cannot currently get baseURL from vercel
// result.content is undefined when the model returns only tool calls with no text output
const content = mapVercelOutput(result.content ?? []);
const latency = (Date.now() - startTime) / 1000;
const providerMetadata = result.providerMetadata;
const additionalTokenValues = extractAdditionalTokenValues(providerMetadata, result.usage);
const webSearchCount = extractWebSearchCount(providerMetadata, result.usage);
// V2 usage has simple numbers, V3 has objects with .total - normalize both
const usageObj = result.usage;
// Extract raw response for providers that include detailed usage metadata
// For Gemini, candidatesTokensDetails is in result.response.body.usageMetadata
const rawUsageData = {
usage: result.usage,
providerMetadata
};
// Include response body usageMetadata if it contains detailed token breakdown (e.g., candidatesTokensDetails)
if (result.response && typeof result.response === 'object') {
const responseBody = result.response.body;
if (responseBody && typeof responseBody === 'object' && 'usageMetadata' in responseBody) {
rawUsageData.rawResponse = {
usageMetadata: responseBody.usageMetadata
};
}
}
const usage = {
inputTokens: extractTokenCount(result.usage.inputTokens),
outputTokens: extractTokenCount(result.usage.outputTokens),
reasoningTokens: extractReasoningTokens(usageObj),
cacheReadInputTokens: extractCacheReadTokens(usageObj),
webSearchCount,
...additionalTokenValues,
rawUsage: rawUsageData
};
adjustAnthropicV3CacheTokens(model, modelId, provider, usage);
// Extract finish reason - V2 returns a string, V3 returns an object with .unified
const rawFinishReason = result.finishReason;
const finishReasonStr = typeof rawFinishReason === 'string' ? rawFinishReason : rawFinishReason && typeof rawFinishReason === 'object' && 'unified' in rawFinishReason ? String(rawFinishReason.unified) : undefined;
await captureAiGeneration(phClient, {
...baseOptions,
model: modelId,
provider: provider,
input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt),
output: content,
latency,
baseURL,
modelParameters: getModelParams(mergedParams),
httpStatus: 200,
usage,
stopReason: finishReasonStr,
tools: availableTools
});
return result;
} catch (error) {
const modelId = model.modelId;
await captureAiGeneration(phClient, {
...baseOptions,
model: modelId,
provider: model.provider,
input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt),
output: [],
latency: 0,
baseURL: '',
modelParameters: getModelParams(mergedParams),
usage: {
inputTokens: 0,
outputTokens: 0
},
error: error,
tools: availableTools
});
throw error;
}
},
writable: true,
configurable: true,
enumerable: false
},
doStream: {
value: async params => {
const startTime = Date.now();
let firstTokenTime;
let generatedText = '';
let reasoningText = '';
let stopReason;
let usage = {};
let providerMetadata = undefined;
const mergedParams = {
...mergedOptions,
...mapVercelParams(params)
};
const modelId = mergedOptions.posthogModelOverride ?? model.modelId;
const provider = mergedOptions.posthogProviderOverride ?? extractProvider(model);
const availableTools = extractAvailableToolCalls('vercel', params);
const baseURL = ''; // cannot currently get baseURL from vercel
// Map to track in-progress tool calls
const toolCallsInProgress = new Map();
try {
const {
stream,
...rest
} = await model.doStream(params);
const transformStream = new TransformStream({
transform(chunk, controller) {
// Handle streaming patterns - compatible with both V2 and V3
if (chunk.type === 'text-delta') {
if (firstTokenTime === undefined) {
firstTokenTime = Date.now();
}
generatedText += chunk.delta;
}
if (chunk.type === 'reasoning-delta') {
if (firstTokenTime === undefined) {
firstTokenTime = Date.now();
}
reasoningText += chunk.delta;
}
// Handle tool call chunks
if (chunk.type === 'tool-input-start') {
if (firstTokenTime === undefined) {
firstTokenTime = Date.now();
}
// Initialize a new tool call
toolCallsInProgress.set(chunk.id, {
toolCallId: chunk.id,
toolName: chunk.toolName,
input: ''
});
}
if (chunk.type === 'tool-input-delta') {
// Accumulate tool call arguments
const toolCall = toolCallsInProgress.get(chunk.id);
if (toolCall) {
toolCall.input += chunk.delta;
}
}
if (chunk.type === 'tool-input-end') {
// Tool call is complete, keep it in the map for final processing
}
if (chunk.type === 'tool-call') {
if (firstTokenTime === undefined) {
firstTokenTime = Date.now();
}
// Direct tool call chunk (complete tool call)
toolCallsInProgress.set(chunk.toolCallId, {
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
input: chunk.input
});
}
if (chunk.type === 'finish') {
providerMetadata = chunk.providerMetadata;
const chunkUsage = chunk.usage || {};
const additionalTokenValues = extractAdditionalTokenValues(providerMetadata, chunkUsage);
usage = {
inputTokens: extractTokenCount(chunk.usage?.inputTokens),
outputTokens: extractTokenCount(chunk.usage?.outputTokens),
reasoningTokens: extractReasoningTokens(chunkUsage),
cacheReadInputTokens: extractCacheReadTokens(chunkUsage),
...additionalTokenValues
};
// Extract finish reason - V2 returns a string, V3 returns an object with .unified
const rawFinishReason = chunk.finishReason;
if (typeof rawFinishReason === 'string') {
stopReason = rawFinishReason;
} else if (rawFinishReason && typeof rawFinishReason === 'object' && 'unified' in rawFinishReason) {
stopReason = String(rawFinishReason.unified);
}
}
controller.enqueue(chunk);
},
flush: async () => {
const latency = (Date.now() - startTime) / 1000;
const timeToFirstToken = firstTokenTime !== undefined ? (firstTokenTime - startTime) / 1000 : undefined;
// Build content array similar to mapVercelOutput structure
const content = [];
if (reasoningText) {
content.push({
type: 'reasoning',
text: truncate(reasoningText)
});
}
if (generatedText) {
content.push({
type: 'text',
text: truncate(generatedText)
});
}
// Add completed tool calls to content
for (const toolCall of toolCallsInProgress.values()) {
if (toolCall.toolName) {
content.push({
type: 'tool-call',
id: toolCall.toolCallId,
function: {
name: toolCall.toolName,
arguments: toolCall.input
}
});
}
}
// Structure output like mapVercelOutput does
const output = content.length > 0 ? [{
role: 'assistant',
content: content.length === 1 && content[0].type === 'text' ? content[0].text : content
}] : [];
const webSearchCount = extractWebSearchCount(providerMetadata, usage);
// Update usage with web search count and raw metadata
const finalUsage = {
...usage,
webSearchCount,
rawUsage: {
usage,
providerMetadata
}
};
adjustAnthropicV3CacheTokens(model, modelId, provider, finalUsage);
await captureAiGeneration(phClient, {
...baseOptions,
model: modelId,
provider: provider,
input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt),
output: output,
latency,
timeToFirstToken,
baseURL,
modelParameters: getModelParams(mergedParams),
httpStatus: 200,
usage: finalUsage,
stopReason,
tools: availableTools
});
}
});
return {
stream: stream.pipeThrough(transformStream),
...rest
};
} catch (error) {
await captureAiGeneration(phClient, {
...baseOptions,
model: modelId,
provider: provider,
input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt),
output: [],
latency: 0,
baseURL: '',
modelParameters: getModelParams(mergedParams),
usage: {
inputTokens: 0,
outputTokens: 0
},
error: error,
tools: availableTools
});
throw error;
}
},
writable: true,
configurable: true,
enumerable: false
}
});
return wrappedModel;
};
exports.withTracing = wrapVercelLanguageModel;
//# sourceMappingURL=index.cjs.map