UNPKG

@posthog/ai

Version:
1,230 lines (1,173 loc) 44.5 kB
import { v4 } from 'uuid'; import { uuidv7 } from '@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 ?? 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 = 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 ?? 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; }; export { wrapVercelLanguageModel as withTracing }; //# sourceMappingURL=index.mjs.map