@posthog/ai
Version:
PostHog Node.js AI integrations
1 lines • 101 kB
Source Map (JSON)
{"version":3,"file":"index.mjs","sources":["../../src/sanitization/base64_recognizer.ts","../../src/sanitization/media_type_context.ts","../../src/sanitization/binary_content_redactor.ts","../../src/sanitization.ts","../../src/utils.ts","../../src/serializeError.ts","../../src/gatewayWarning.ts","../../src/langchain/callbacks.ts"],"sourcesContent":["const DATA_URL_PREFIX_RE = /^data:([^;,\\s]+)(?:;[^;,\\s]+)*;base64,/i\nconst BASE64_ALPHABET_RE = /^[A-Za-z0-9+/_=-]+$/\n\nexport type Base64Recognition = { kind: 'data-url'; mediaType: string } | { kind: 'raw' } | { kind: 'none' }\n\nexport class Base64Recognizer {\n recognize(value: string, minLength: number): Base64Recognition {\n const dataUrl = DATA_URL_PREFIX_RE.exec(value)\n if (dataUrl) return { kind: 'data-url', mediaType: dataUrl[1] }\n\n if (value.length < minLength) return { kind: 'none' }\n\n const confidencePrefix = value.slice(0, minLength)\n if (BASE64_ALPHABET_RE.test(confidencePrefix)) {\n return { kind: 'raw' }\n } else {\n return { kind: 'none' }\n }\n }\n}\n","const MIME_HINT_KEYS = ['mediaType', 'media_type', 'mimeType', 'mime_type'] as const\n\nconst STRONG_CONTEXT_KEYS = new Set([\n 'data',\n 'file_data',\n 'fileData',\n 'image_url',\n 'imageUrl',\n 'video_url',\n 'videoUrl',\n 'audio',\n 'audio_data',\n 'audioData',\n 'inline_data',\n 'inlineData',\n 'source',\n 'result',\n])\n\nconst STRONG_CONTEXT_TYPES = new Set([\n 'image',\n 'image_url',\n 'input_image',\n 'audio',\n 'input_audio',\n 'video',\n 'video_url',\n 'file',\n 'input_file',\n 'document',\n 'media',\n 'file-data',\n])\n\nconst FILE_FAMILY_TYPES = new Set(['file', 'input_file', 'document', 'media', 'file-data'])\n\nconst KNOWN_AUDIO_FORMATS = new Set(['wav', 'mp3', 'ogg', 'flac', 'm4a', 'aac', 'webm'])\n\nexport class MediaTypeContext {\n static readonly EMPTY = new MediaTypeContext(undefined, undefined)\n\n constructor(\n private readonly parent: Record<string, unknown> | undefined,\n private readonly key: string | undefined\n ) {}\n\n inferMediaType(): string | undefined {\n return (\n this.inferFromSiblingMime() ?? this.inferFromSiblingFormat() ?? this.inferFromParentType() ?? this.inferFromKey()\n )\n }\n\n inferFromSiblingMime(): string | undefined {\n if (!this.parent) return undefined\n for (const hint of MIME_HINT_KEYS) {\n const v = this.parent[hint]\n if (typeof v === 'string') return v\n }\n return undefined\n }\n\n inferFromSiblingFormat(): string | undefined {\n if (!this.parent) return undefined\n const fmt = this.parent.format\n if (typeof fmt === 'string' && KNOWN_AUDIO_FORMATS.has(fmt.toLowerCase())) {\n return `audio/${fmt.toLowerCase()}`\n }\n return undefined\n }\n\n inferFromParentType(): string | undefined {\n if (!this.parent) return undefined\n const t = this.parent.type\n if (typeof t !== 'string') return undefined\n if (t === 'image' || t === 'image_url' || t === 'input_image') return 'image'\n if (t === 'audio' || t === 'input_audio') return 'audio'\n if (t === 'video' || t === 'video_url') return 'video'\n if (FILE_FAMILY_TYPES.has(t)) return 'application/octet-stream'\n return undefined\n }\n\n inferFromKey(): string | undefined {\n if (!this.key) return undefined\n const key = this.key.toLowerCase()\n if (key.includes('audio')) return 'audio'\n if (key.includes('video')) return 'video'\n if (key.includes('image')) return 'image'\n if (key.includes('file') || key.includes('document')) return 'application/octet-stream'\n return undefined\n }\n\n signalsBinary(): boolean {\n if (this.parent) {\n for (const hint of MIME_HINT_KEYS) {\n if (typeof this.parent[hint] === 'string') return true\n }\n const fmt = this.parent.format\n if (typeof fmt === 'string' && KNOWN_AUDIO_FORMATS.has(fmt.toLowerCase())) return true\n const t = this.parent.type\n if (typeof t === 'string' && STRONG_CONTEXT_TYPES.has(t)) return true\n }\n if (this.key && STRONG_CONTEXT_KEYS.has(this.key)) return true\n return false\n }\n}\n","import { Base64Recognizer } from './base64_recognizer'\nimport { MediaTypeContext } from './media_type_context'\n\nconst STRONG_CONTEXT_MIN_LENGTH = 64\nconst WEAK_CONTEXT_MIN_LENGTH = 1024\n\nexport class BinaryContentRedactor {\n private visited: WeakSet<object> = new WeakSet()\n\n constructor(private readonly recognizer: Base64Recognizer = new Base64Recognizer()) {}\n\n redact<T>(value: T): T\n redact(value: unknown): unknown {\n if (this.isMultimodalEnabled()) return value\n this.visited = new WeakSet()\n return this.walk(value, MediaTypeContext.EMPTY)\n }\n\n private walk(value: unknown, ctx: MediaTypeContext): unknown {\n if (value === null || value === undefined) return value\n if (typeof value === 'string') return this.redactString(value, ctx)\n if (typeof value !== 'object') return value\n\n // Buffer extends Uint8Array, so this branch catches both.\n if (typeof Uint8Array !== 'undefined' && value instanceof Uint8Array) {\n return this.placeholderFor(ctx.inferMediaType())\n }\n\n if (this.visited.has(value)) return null\n this.visited.add(value)\n\n if (Array.isArray(value)) {\n return value.map((item) => this.walk(item, ctx))\n }\n\n const obj = value as Record<string, unknown>\n const out: Record<string, unknown> = {}\n for (const k of Object.keys(obj)) {\n out[k] = this.walk(obj[k], new MediaTypeContext(obj, k))\n }\n return out\n }\n\n private redactString(value: string, ctx: MediaTypeContext): string {\n const minLength = ctx.signalsBinary() ? STRONG_CONTEXT_MIN_LENGTH : WEAK_CONTEXT_MIN_LENGTH\n const recognition = this.recognizer.recognize(value, minLength)\n switch (recognition.kind) {\n case 'data-url':\n return this.placeholderFor(recognition.mediaType)\n case 'raw':\n return this.placeholderFor(ctx.inferMediaType())\n case 'none':\n return value\n }\n }\n\n private placeholderFor(mediaType?: string): string {\n if (!mediaType) return '[base64 redacted]'\n if (mediaType === 'application/octet-stream') return '[base64 file redacted]'\n return `[base64 ${mediaType} redacted]`\n }\n\n private isMultimodalEnabled(): boolean {\n const val = process.env._INTERNAL_LLMA_MULTIMODAL || ''\n return val.toLowerCase() === 'true' || val === '1' || val.toLowerCase() === 'yes'\n }\n}\n","import { BinaryContentRedactor } from './sanitization/binary_content_redactor'\n\nconst redactor = new BinaryContentRedactor()\n\nexport function redactBase64DataUrl(str: string): string\nexport function redactBase64DataUrl(str: unknown): unknown\nexport function redactBase64DataUrl(str: unknown): unknown {\n return redactor.redact(str)\n}\n\nexport const sanitizeOpenAI = (data: unknown): unknown => redactor.redact(data)\nexport const sanitizeOpenAIResponse = (data: unknown): unknown => redactor.redact(data)\nexport const sanitizeAnthropic = (data: unknown): unknown => redactor.redact(data)\nexport const sanitizeGemini = (data: unknown): unknown => redactor.redact(data)\nexport const sanitizeLangChain = (data: unknown): unknown => redactor.redact(data)\n","import { PostHog } from 'posthog-node'\nimport type OpenAIOrignal from 'openai'\nimport type AnthropicOriginal from '@anthropic-ai/sdk'\nimport type { ResponseCreateParamsWithTools } from 'openai/lib/ResponsesParser'\nimport type {\n FormattedMessage,\n FormattedContent,\n FormattedAudioContent,\n FormattedImageContent,\n FormattedDocumentContent,\n} from './types'\nimport { v4 as uuidv4 } from 'uuid'\nimport { isString } from './typeGuards'\nimport { redactBase64DataUrl } from './sanitization'\n\ntype ChatCompletionCreateParamsBase = OpenAIOrignal.Chat.Completions.ChatCompletionCreateParams\ntype MessageCreateParams = AnthropicOriginal.Messages.MessageCreateParams\ntype ResponseCreateParams = OpenAIOrignal.Responses.ResponseCreateParams\ntype EmbeddingCreateParams = OpenAIOrignal.EmbeddingCreateParams\ntype TranscriptionCreateParams = OpenAIOrignal.Audio.Transcriptions.TranscriptionCreateParams\n\nconst TOKEN_PROPERTY_KEYS = new Set([\n '$ai_input_tokens',\n '$ai_output_tokens',\n '$ai_cache_read_input_tokens',\n '$ai_cache_creation_input_tokens',\n '$ai_total_tokens',\n '$ai_reasoning_tokens',\n])\n\nexport function getTokensSource(posthogProperties?: Record<string, unknown>): string {\n if (posthogProperties && Object.keys(posthogProperties).some((key) => TOKEN_PROPERTY_KEYS.has(key))) {\n return 'passthrough'\n }\n return 'sdk'\n}\n\n// limit large outputs by truncating to 200kb (approx 200k bytes)\nexport const MAX_OUTPUT_SIZE = 200000\nconst STRING_FORMAT = 'utf8'\n\n// Reused across calls to avoid per-invocation allocation; truncate() runs\n// hundreds of times for prompts with many parts.\nconst sharedTextEncoder = new TextEncoder()\nconst sharedTextDecoder = new TextDecoder(STRING_FORMAT, { fatal: false })\n\nexport const utf8ByteLength = (str: string): number => sharedTextEncoder.encode(str).byteLength\n\n/**\n * Safely converts content to a string, preserving structure for objects/arrays.\n * - If content is already a string, returns it as-is\n * - If content is an object or array, stringifies it with JSON.stringify to preserve structure\n * - Otherwise, converts to string with String()\n *\n * This prevents the \"[object Object]\" bug when objects are naively converted to strings.\n *\n * @param content - The content to convert to a string\n * @returns A string representation that preserves structure for complex types\n */\nexport function toContentString(content: unknown): string {\n if (typeof content === 'string') {\n return content\n }\n if (content !== undefined && content !== null && typeof content === 'object') {\n try {\n return JSON.stringify(content)\n } catch {\n // Fallback for circular refs, BigInt, or objects with throwing toJSON\n return String(content)\n }\n }\n return String(content)\n}\n\nexport interface MonitoringEventPropertiesWithDefaults {\n distinctId?: string\n traceId: string\n properties?: Record<string, any>\n privacyMode: boolean\n groups?: Record<string, any>\n modelOverride?: string\n providerOverride?: string\n costOverride?: CostOverride\n captureImmediate?: boolean\n}\n\nexport type MonitoringEventProperties = Partial<MonitoringEventPropertiesWithDefaults>\n\nexport type MonitoringParams = {\n [K in keyof MonitoringEventProperties as `posthog${Capitalize<string & K>}`]: MonitoringEventProperties[K]\n}\n\nexport interface CostOverride {\n inputCost: number\n outputCost: number\n}\n\nexport const getModelParams = (\n params:\n | ((\n | ChatCompletionCreateParamsBase\n | MessageCreateParams\n | ResponseCreateParams\n | ResponseCreateParamsWithTools\n | EmbeddingCreateParams\n | TranscriptionCreateParams\n ) &\n MonitoringParams)\n | null\n): Record<string, any> => {\n if (!params) {\n return {}\n }\n const modelParams: Record<string, any> = {}\n const paramKeys = [\n 'temperature',\n 'max_tokens',\n 'max_completion_tokens',\n 'top_p',\n 'frequency_penalty',\n 'presence_penalty',\n 'n',\n 'stop',\n 'stream',\n 'streaming',\n 'language',\n 'response_format',\n 'timestamp_granularities',\n ] as const\n\n for (const key of paramKeys) {\n if (key in params && (params as any)[key] !== undefined) {\n modelParams[key] = (params as any)[key]\n }\n }\n return modelParams\n}\n\n/**\n * Helper to format responses (non-streaming) for consumption\n */\nexport const formatResponse = (response: any, provider: string): FormattedMessage[] => {\n if (!response) {\n return []\n }\n if (provider === 'anthropic') {\n return formatResponseAnthropic(response)\n } else if (provider === 'openai') {\n return formatResponseOpenAI(response)\n } else if (provider === 'gemini') {\n return formatResponseGemini(response)\n }\n return []\n}\n\nexport const formatResponseAnthropic = (response: any): FormattedMessage[] => {\n const output: FormattedMessage[] = []\n const content: FormattedContent = []\n\n for (const choice of response.content ?? []) {\n if (choice?.type === 'text' && choice?.text) {\n content.push({ type: 'text', text: choice.text })\n } else if (choice?.type === 'tool_use' && choice?.name && choice?.id) {\n content.push({\n type: 'function',\n id: choice.id,\n function: {\n name: choice.name,\n arguments: choice.input || {},\n },\n })\n }\n }\n\n if (content.length > 0) {\n output.push({\n role: 'assistant',\n content,\n })\n }\n\n return output\n}\n\nexport const formatResponseOpenAI = (response: any): FormattedMessage[] => {\n const output: FormattedMessage[] = []\n\n if (response.choices) {\n for (const choice of response.choices) {\n const content: FormattedContent = []\n let role = 'assistant'\n\n if (choice.message) {\n if (choice.message.role) {\n role = choice.message.role\n }\n\n if (choice.message.content) {\n content.push({ type: 'text', text: choice.message.content })\n }\n\n if (choice.message.tool_calls) {\n for (const toolCall of choice.message.tool_calls) {\n content.push({\n type: 'function',\n id: toolCall.id,\n function: {\n name: toolCall.function.name,\n arguments: toolCall.function.arguments,\n },\n })\n }\n }\n\n // Handle audio output (gpt-4o-audio-preview)\n if (choice.message.audio) {\n content.push({\n type: 'audio',\n ...choice.message.audio,\n })\n }\n }\n\n if (content.length > 0) {\n output.push({\n role,\n content,\n })\n }\n }\n }\n\n // Handle Responses API format\n if (response.output) {\n const content: FormattedContent = []\n let role = 'assistant'\n\n for (const item of response.output) {\n if (item.type === 'message') {\n role = item.role\n\n if (item.content && Array.isArray(item.content)) {\n for (const contentItem of item.content) {\n if (contentItem.type === 'output_text' && contentItem.text) {\n content.push({ type: 'text', text: contentItem.text })\n } else if (contentItem.text) {\n content.push({ type: 'text', text: contentItem.text })\n } else if (contentItem.type === 'input_image' && contentItem.image_url) {\n content.push({\n type: 'image',\n image: contentItem.image_url,\n })\n }\n }\n } else if (item.content) {\n content.push({ type: 'text', text: String(item.content) })\n }\n } else if (item.type === 'function_call') {\n content.push({\n type: 'function',\n id: item.call_id || item.id || '',\n function: {\n name: item.name,\n arguments: item.arguments || {},\n },\n })\n }\n }\n\n if (content.length > 0) {\n output.push({\n role,\n content,\n })\n }\n }\n\n return output\n}\n\nexport const buildInlineDataBlock = (\n mimeType: string,\n data: string\n): FormattedAudioContent | FormattedImageContent | FormattedDocumentContent => {\n if (mimeType.startsWith('audio/')) {\n return { type: 'audio', mime_type: mimeType, data }\n }\n if (mimeType.startsWith('image/')) {\n return { type: 'image', inline_data: { mime_type: mimeType, data } }\n }\n return { type: 'document', inline_data: { mime_type: mimeType, data } }\n}\n\nexport const formatResponseGemini = (response: any): FormattedMessage[] => {\n const output: FormattedMessage[] = []\n\n if (response.candidates && Array.isArray(response.candidates)) {\n for (const candidate of response.candidates) {\n if (candidate.content && candidate.content.parts) {\n const content: FormattedContent = []\n\n for (const part of candidate.content.parts) {\n if (part.text) {\n content.push({ type: 'text', text: part.text })\n } else if (part.functionCall) {\n content.push({\n type: 'function',\n function: {\n name: part.functionCall.name,\n arguments: part.functionCall.args,\n },\n })\n } else if (part.inlineData) {\n // Handle inline data (images, audio, documents)\n const mimeType = part.inlineData.mimeType || part.inlineData.mime_type || 'application/octet-stream'\n let data = part.inlineData.data\n\n // Handle binary data (Uint8Array/Buffer -> base64)\n if (data instanceof Uint8Array) {\n if (typeof Buffer !== 'undefined') {\n data = Buffer.from(data).toString('base64')\n } else {\n let binary = ''\n for (let i = 0; i < data.length; i++) {\n binary += String.fromCharCode(data[i])\n }\n data = btoa(binary)\n }\n }\n\n // Sanitize base64 data for images and other large inline data\n data = redactBase64DataUrl(data)\n\n content.push(buildInlineDataBlock(mimeType, data))\n }\n }\n\n if (content.length > 0) {\n output.push({\n role: 'assistant',\n content,\n })\n }\n } else if (candidate.text) {\n output.push({\n role: 'assistant',\n content: [{ type: 'text', text: candidate.text }],\n })\n }\n }\n } else if (response.text) {\n output.push({\n role: 'assistant',\n content: [{ type: 'text', text: response.text }],\n })\n }\n\n return output\n}\n\nexport const mergeSystemPrompt = (params: MessageCreateParams & MonitoringParams, provider: string): any => {\n if (provider == 'anthropic') {\n const messages = params.messages || []\n if (!(params as any).system) {\n return messages\n }\n const systemMessage = (params as any).system\n return [{ role: 'system', content: systemMessage }, ...messages]\n }\n return params.messages\n}\n\nexport const withPrivacyMode = (client: PostHog, privacyMode: boolean, input: any): any => {\n return (client as any).privacy_mode || privacyMode ? null : input\n}\n\nfunction toSafeString(input: unknown): string {\n if (input === undefined || input === null) {\n return ''\n }\n if (typeof input === 'string') {\n return input\n }\n try {\n return JSON.stringify(input)\n } catch {\n console.warn('Failed to stringify input', input)\n return ''\n }\n}\n\nexport const truncate = (input: unknown): string => {\n const str = toSafeString(input)\n if (str === '') {\n return ''\n }\n\n // Check if we need to truncate and ensure STRING_FORMAT is respected\n const buffer = sharedTextEncoder.encode(str)\n if (buffer.length <= MAX_OUTPUT_SIZE) {\n // Ensure STRING_FORMAT is respected\n return sharedTextDecoder.decode(buffer)\n }\n\n // Truncate the buffer and ensure a valid string is returned.\n // fatal: false means we get U+FFFD at the end if truncation broke the encoding.\n const truncatedBuffer = buffer.slice(0, MAX_OUTPUT_SIZE)\n let truncatedStr = sharedTextDecoder.decode(truncatedBuffer)\n if (truncatedStr.endsWith('\\uFFFD')) {\n truncatedStr = truncatedStr.slice(0, -1)\n }\n return `${truncatedStr}... [truncated]`\n}\n\n/**\n * Calculate web search count from raw API response.\n *\n * Uses a two-tier detection strategy:\n * Priority 1 (Exact Count): Count actual web search calls when available\n * Priority 2 (Binary Detection): Return 1 if web search indicators are present, 0 otherwise\n *\n * @param result - Raw API response from any provider (OpenAI, Perplexity, OpenRouter, Gemini, etc.)\n * @returns Number of web searches performed (exact count or binary 1/0)\n */\nexport function calculateWebSearchCount(result: unknown): number {\n if (!result || typeof result !== 'object') {\n return 0\n }\n\n // Priority 1: Exact Count\n // Check for OpenAI Responses API web_search_call items\n if ('output' in result && Array.isArray(result.output)) {\n let count = 0\n\n for (const item of result.output) {\n if (typeof item === 'object' && item !== null && 'type' in item && item.type === 'web_search_call') {\n count++\n }\n }\n\n if (count > 0) {\n return count\n }\n }\n\n // Priority 2: Binary Detection (1 or 0)\n\n // Check for citations at root level (Perplexity)\n if ('citations' in result && Array.isArray(result.citations) && result.citations.length > 0) {\n return 1\n }\n\n // Check for search_results at root level (Perplexity via OpenRouter)\n if ('search_results' in result && Array.isArray(result.search_results) && result.search_results.length > 0) {\n return 1\n }\n\n // Check for usage.search_context_size (Perplexity via OpenRouter)\n if ('usage' in result && typeof result.usage === 'object' && result.usage !== null) {\n if ('search_context_size' in result.usage && result.usage.search_context_size) {\n return 1\n }\n }\n\n // Check for annotations with url_citation in choices[].message or choices[].delta (OpenAI/Perplexity)\n if ('choices' in result && Array.isArray(result.choices)) {\n for (const choice of result.choices) {\n if (typeof choice === 'object' && choice !== null) {\n // Check both message (non-streaming) and delta (streaming) for annotations\n const content = ('message' in choice ? choice.message : null) || ('delta' in choice ? choice.delta : null)\n\n if (typeof content === 'object' && content !== null && 'annotations' in content) {\n const annotations = content.annotations\n\n if (Array.isArray(annotations)) {\n const hasUrlCitation = annotations.some((ann: unknown) => {\n return typeof ann === 'object' && ann !== null && 'type' in ann && ann.type === 'url_citation'\n })\n\n if (hasUrlCitation) {\n return 1\n }\n }\n }\n }\n }\n }\n\n // Check for annotations in output[].content[] (OpenAI Responses API)\n if ('output' in result && Array.isArray(result.output)) {\n for (const item of result.output) {\n if (typeof item === 'object' && item !== null && 'content' in item) {\n const content = item.content\n\n if (Array.isArray(content)) {\n for (const contentItem of content) {\n if (typeof contentItem === 'object' && contentItem !== null && 'annotations' in contentItem) {\n const annotations = contentItem.annotations\n\n if (Array.isArray(annotations)) {\n const hasUrlCitation = annotations.some((ann: unknown) => {\n return typeof ann === 'object' && ann !== null && 'type' in ann && ann.type === 'url_citation'\n })\n\n if (hasUrlCitation) {\n return 1\n }\n }\n }\n }\n }\n }\n }\n }\n\n // Check for grounding_metadata (Gemini)\n if ('candidates' in result && Array.isArray(result.candidates)) {\n for (const candidate of result.candidates) {\n if (\n typeof candidate === 'object' &&\n candidate !== null &&\n 'grounding_metadata' in candidate &&\n candidate.grounding_metadata\n ) {\n return 1\n }\n }\n }\n\n return 0\n}\n\n/**\n * Extract available tool calls from the request parameters.\n * These are the tools provided to the LLM, not the tool calls in the response.\n */\nexport const extractAvailableToolCalls = (provider: string, params: any): unknown[] | null => {\n if (provider === 'anthropic') {\n if (params.tools) {\n return params.tools\n }\n\n return null\n } else if (provider === 'gemini') {\n if (params.config && params.config.tools) {\n return params.config.tools\n }\n\n return null\n } else if (provider === 'openai') {\n if (params.tools) {\n return params.tools\n }\n\n return null\n } else if (provider === 'vercel') {\n if (params.tools) {\n return params.tools\n }\n\n return null\n }\n\n return null\n}\n\nexport enum AIEvent {\n Generation = '$ai_generation',\n Embedding = '$ai_embedding',\n}\n\nexport function sanitizeValues(obj: any): any {\n if (obj === undefined || obj === null) {\n return obj\n }\n const jsonSafe = JSON.parse(JSON.stringify(obj))\n if (typeof jsonSafe === 'string') {\n // Sanitize lone surrogates by round-tripping through UTF-8\n return new TextDecoder().decode(new TextEncoder().encode(jsonSafe))\n } else if (Array.isArray(jsonSafe)) {\n return jsonSafe.map(sanitizeValues)\n } else if (jsonSafe && typeof jsonSafe === 'object') {\n return Object.fromEntries(Object.entries(jsonSafe).map(([k, v]) => [k, sanitizeValues(v)]))\n }\n return jsonSafe\n}\n\nconst POSTHOG_PARAMS_MAP: Record<keyof MonitoringParams, string> = {\n posthogDistinctId: 'distinctId',\n posthogTraceId: 'traceId',\n posthogProperties: 'properties',\n posthogPrivacyMode: 'privacyMode',\n posthogGroups: 'groups',\n posthogModelOverride: 'modelOverride',\n posthogProviderOverride: 'providerOverride',\n posthogCostOverride: 'costOverride',\n posthogCaptureImmediate: 'captureImmediate',\n}\n\nexport function extractPosthogParams<T>(body: T & MonitoringParams): {\n providerParams: T\n posthogParams: MonitoringEventPropertiesWithDefaults\n} {\n const providerParams: Record<string, unknown> = {}\n const posthogParams: Record<string, unknown> = {}\n\n for (const [key, value] of Object.entries(body)) {\n if (POSTHOG_PARAMS_MAP[key as keyof MonitoringParams]) {\n posthogParams[POSTHOG_PARAMS_MAP[key as keyof MonitoringParams]] = value\n } else if (key.startsWith('posthog')) {\n console.warn(`Unknown Posthog parameter ${key}`)\n } else {\n providerParams[key] = value\n }\n }\n\n return {\n providerParams: providerParams as T,\n posthogParams: addDefaults(posthogParams),\n }\n}\n\nfunction addDefaults(params: MonitoringEventProperties): MonitoringEventPropertiesWithDefaults {\n return {\n ...params,\n privacyMode: params.privacyMode ?? false,\n traceId: params.traceId ?? uuidv4(),\n }\n}\n\nexport function formatOpenAIResponsesInput(input: unknown, instructions?: string | null): FormattedMessage[] {\n const messages: FormattedMessage[] = []\n\n if (instructions) {\n messages.push({\n role: 'system',\n content: instructions,\n })\n }\n\n if (Array.isArray(input)) {\n for (const item of input) {\n if (typeof item === 'string') {\n messages.push({ role: 'user', content: item })\n } else if (item && typeof item === 'object') {\n const obj = item as Record<string, unknown>\n const role = isString(obj.role) ? obj.role : 'user'\n\n // Handle content properly - preserve structure for objects/arrays\n const content = obj.content ?? obj.text ?? item\n messages.push({ role, content: toContentString(content) })\n } else {\n messages.push({ role: 'user', content: toContentString(item) })\n }\n }\n } else if (typeof input === 'string') {\n messages.push({ role: 'user', content: input })\n } else if (input) {\n messages.push({ role: 'user', content: toContentString(input) })\n }\n\n return messages\n}\n","import { sanitizeValues } from './utils'\n\nconst DEFAULT_MAX_DEPTH = 3\nconst MAX_STACK_LINES = 20\n\nexport function serializeError(value: unknown, depth = DEFAULT_MAX_DEPTH): unknown {\n if (depth < 0 || value === null || typeof value !== 'object') {\n return value\n }\n if (value instanceof Error) {\n const out: Record<string, unknown> = {\n name: value.name,\n message: value.message,\n stack: truncateStack(value.stack),\n }\n for (const key of Object.keys(value)) {\n out[key] = serializeError((value as unknown as Record<string, unknown>)[key], depth - 1)\n }\n if (value.cause !== undefined) {\n out.cause = serializeError(value.cause, depth - 1)\n }\n return out\n }\n if (Array.isArray(value)) {\n return value.map((item) => serializeError(item, depth - 1))\n }\n return value\n}\n\nexport function stringifyError(error: unknown): string {\n try {\n return JSON.stringify(sanitizeValues(serializeError(error)))\n } catch {\n if (error instanceof Error) {\n return JSON.stringify({ name: error.name, message: error.message })\n }\n return JSON.stringify({ message: String(error) })\n }\n}\n\nfunction truncateStack(stack: string | undefined): string | undefined {\n if (!stack) {\n return stack\n }\n const lines = stack.split('\\n')\n if (lines.length <= MAX_STACK_LINES) {\n return stack\n }\n return [...lines.slice(0, MAX_STACK_LINES), '... (truncated)'].join('\\n')\n}\n","// Warn when a wrapper's base_url points at the PostHog AI Gateway: the gateway\n// emits its own $ai_generation, so each call would be captured (and, for billable\n// products, billed) twice. We only warn — the wrapper's event carries data the\n// gateway never sees (groups, custom properties, trace hierarchy).\n\n// Keep in sync with the gateway's deployed hosts (see services/llm-gateway in the\n// main repo). gateway.us.posthog.com is live today; the rest are listed ahead of\n// any traffic moving to them.\nexport const POSTHOG_AI_GATEWAY_HOSTS: readonly string[] = [\n 'gateway.posthog.com',\n 'gateway.us.posthog.com',\n 'gateway.eu.posthog.com',\n 'ai-gateway.us.posthog.com',\n 'ai-gateway.eu.posthog.com',\n]\n\n// Swap for the dedicated AI Gateway page once it ships.\nconst GATEWAY_DOCS_URL = 'https://posthog.com/docs/ai-observability'\n\nconst extractHost = (baseURL: string): string | undefined => {\n try {\n // Tolerate bare hosts that omit a scheme, e.g. \"gateway.us.posthog.com/v1\".\n const hasScheme = /^[a-z][a-z0-9+.-]*:\\/\\//i.test(baseURL)\n return new URL(hasScheme ? baseURL : `https://${baseURL}`).hostname.toLowerCase()\n } catch {\n return undefined\n }\n}\n\nexport const isPostHogAiGatewayUrl = (baseURL: string | undefined | null): boolean => {\n if (!baseURL) {\n return false\n }\n const host = extractHost(baseURL)\n return host !== undefined && POSTHOG_AI_GATEWAY_HOSTS.includes(host)\n}\n\n// Warns on every gateway call by design: the misconfiguration is impossible to\n// miss that way, and a doubled bill is worse than noisy logs.\nexport const warnIfPostHogAiGateway = (baseURL: string | undefined | null): void => {\n if (!isPostHogAiGatewayUrl(baseURL)) {\n return\n }\n console.warn(\n '[PostHog] The PostHog AI wrapper is pointed at the PostHog AI Gateway. ' +\n 'Both capture $ai_generation, so every call is double-counted and double-billed. ' +\n `Use one or the other — see ${GATEWAY_DOCS_URL}.`\n )\n}\n\n// OTel spans don't pass through captureAiGeneration, so detect the gateway from\n// the span's host/URL attributes instead. These follow the GenAI / HTTP semantic\n// conventions: `server.address` is a bare host, `url.full` a full URL, both of\n// which isPostHogAiGatewayUrl accepts.\nconst OTEL_GATEWAY_URL_ATTRIBUTES = ['server.address', 'url.full'] as const\n\nexport const warnIfPostHogAiGatewayOtelAttributes = (attributes: Record<string, unknown> | undefined): void => {\n if (!attributes) {\n return\n }\n for (const key of OTEL_GATEWAY_URL_ATTRIBUTES) {\n const value = attributes[key]\n if (typeof value === 'string' && isPostHogAiGatewayUrl(value)) {\n warnIfPostHogAiGateway(value)\n return\n }\n }\n}\n","import { PostHog } from 'posthog-node'\nimport { withPrivacyMode, getModelParams, toContentString } from '../utils'\nimport { BaseCallbackHandler } from '@langchain/core/callbacks/base'\nimport { version } from '../../package.json'\nimport type { Serialized } from '@langchain/core/load/serializable'\nimport type { ChainValues } from '@langchain/core/utils/types'\nimport type { LLMResult } from '@langchain/core/outputs'\nimport type { AgentAction, AgentFinish } from '@langchain/core/agents'\nimport type { DocumentInterface } from '@langchain/core/documents'\nimport { ToolCall } from '@langchain/core/messages/tool'\nimport { BaseMessage } from '@langchain/core/messages'\nimport { sanitizeLangChain } from '../sanitization'\nimport { stringifyError } from '../serializeError'\nimport { warnIfPostHogAiGateway } from '../gatewayWarning'\n\ninterface SpanMetadata {\n /** Name of the trace/span (e.g. chain name) */\n name: string\n /** Timestamp (in ms) when the run started */\n startTime: number\n /** Timestamp (in ms) when the run ended (if already finished) */\n endTime?: number\n /** The input state */\n input?: any\n}\n\ninterface GenerationMetadata extends SpanMetadata {\n /** Provider used (e.g. openai, anthropic) */\n provider?: string\n /** Model name used in the generation */\n model?: string\n /** The model parameters (temperature, max_tokens, etc.) */\n modelParams?: Record<string, any>\n /** The base URL—for example, the API base used */\n baseUrl?: string\n /** The tools used in the generation */\n tools?: Record<string, any>\n}\n\n/** A run may either be a Span or a Generation */\ntype RunMetadata = SpanMetadata | GenerationMetadata\n\n/** Storage for run metadata */\ntype RunMetadataStorage = { [runId: string]: RunMetadata }\n\nexport class LangChainCallbackHandler extends BaseCallbackHandler {\n public name = 'PosthogCallbackHandler'\n private client: PostHog\n private distinctId?: string | number\n private traceId?: string | number\n private properties: Record<string, any>\n private privacyMode: boolean\n private groups: Record<string, any>\n private debug: boolean\n\n private runs: RunMetadataStorage = {}\n private parentTree: { [runId: string]: string } = {}\n\n constructor(options: {\n client: PostHog\n distinctId?: string | number\n traceId?: string | number\n properties?: Record<string, any>\n privacyMode?: boolean\n groups?: Record<string, any>\n debug?: boolean\n }) {\n if (!options.client) {\n throw new Error('PostHog client is required')\n }\n super()\n this.client = options.client\n this.distinctId = options.distinctId\n this.traceId = options.traceId\n this.properties = options.properties || {}\n this.privacyMode = options.privacyMode || false\n this.groups = options.groups || {}\n this.debug = options.debug || false\n }\n\n // ===== CALLBACK METHODS =====\n\n public handleChainStart(\n chain: Serialized,\n inputs: ChainValues,\n runId: string,\n parentRunId?: string,\n tags?: string[],\n metadata?: Record<string, unknown>,\n _runType?: string,\n runName?: string\n ): void {\n this._logDebugEvent('on_chain_start', runId, parentRunId, { inputs, tags })\n this._setParentOfRun(runId, parentRunId)\n this._setTraceOrSpanMetadata(chain, inputs, runId, parentRunId, metadata, tags, runName)\n }\n\n public handleChainEnd(\n outputs: ChainValues,\n runId: string,\n parentRunId?: string,\n tags?: string[],\n _kwargs?: { inputs?: Record<string, unknown> }\n ): void {\n this._logAndPopTraceOrSpan('on_chain_end', runId, parentRunId, { outputs, tags }, outputs)\n }\n\n public handleChainError(\n error: Error,\n runId: string,\n parentRunId?: string,\n tags?: string[],\n _kwargs?: { inputs?: Record<string, unknown> }\n ): void {\n this._logAndPopTraceOrSpan('on_chain_error', runId, parentRunId, { error, tags }, error)\n }\n\n public handleChatModelStart(\n serialized: Serialized,\n messages: BaseMessage[][],\n runId: string,\n parentRunId?: string,\n extraParams?: Record<string, unknown>,\n tags?: string[],\n metadata?: Record<string, unknown>,\n runName?: string\n ): void {\n this._logDebugEvent('on_chat_model_start', runId, parentRunId, { messages, tags })\n this._setParentOfRun(runId, parentRunId)\n // Flatten the two-dimensional messages and convert each message to a plain object\n const input = messages.flat().map((m) => this._convertMessageToDict(m))\n this._setLLMMetadata(serialized, runId, input, metadata, extraParams, runName)\n }\n\n public handleLLMStart(\n serialized: Serialized,\n prompts: string[],\n runId: string,\n parentRunId?: string,\n extraParams?: Record<string, unknown>,\n tags?: string[],\n metadata?: Record<string, unknown>,\n runName?: string\n ): void {\n this._logDebugEvent('on_llm_start', runId, parentRunId, { prompts, tags })\n this._setParentOfRun(runId, parentRunId)\n this._setLLMMetadata(serialized, runId, prompts, metadata, extraParams, runName)\n }\n\n public handleLLMEnd(\n output: LLMResult,\n runId: string,\n parentRunId?: string,\n tags?: string[],\n _extraParams?: Record<string, unknown>\n ): void {\n this._logAndPopGeneration('on_llm_end', runId, parentRunId, { output, tags }, output)\n }\n\n public handleLLMError(\n err: Error,\n runId: string,\n parentRunId?: string,\n tags?: string[],\n _extraParams?: Record<string, unknown>\n ): void {\n this._logAndPopGeneration('on_llm_error', runId, parentRunId, { err, tags }, err)\n }\n\n public handleToolStart(\n tool: Serialized,\n input: string,\n runId: string,\n parentRunId?: string,\n tags?: string[],\n metadata?: Record<string, unknown>,\n runName?: string\n ): void {\n this._logAndSetTraceOrSpan(\n 'on_tool_start',\n tool,\n input,\n runId,\n parentRunId,\n { input, tags },\n tags,\n metadata,\n runName\n )\n }\n\n public handleToolEnd(output: any, runId: string, parentRunId?: string, tags?: string[]): void {\n this._logAndPopTraceOrSpan('on_tool_end', runId, parentRunId, { output, tags }, output)\n }\n\n public handleToolError(err: Error, runId: string, parentRunId?: string, tags?: string[]): void {\n this._logAndPopTraceOrSpan('on_tool_error', runId, parentRunId, { err, tags }, err)\n }\n\n public handleRetrieverStart(\n retriever: Serialized,\n query: string,\n runId: string,\n parentRunId?: string,\n tags?: string[],\n metadata?: Record<string, unknown>,\n name?: string\n ): void {\n this._logAndSetTraceOrSpan(\n 'on_retriever_start',\n retriever,\n query,\n runId,\n parentRunId,\n { query, tags },\n tags,\n metadata,\n name\n )\n }\n\n public handleRetrieverEnd(\n documents: DocumentInterface[],\n runId: string,\n parentRunId?: string,\n tags?: string[]\n ): void {\n this._logAndPopTraceOrSpan('on_retriever_end', runId, parentRunId, { documents, tags }, documents)\n }\n\n public handleRetrieverError(err: Error, runId: string, parentRunId?: string, tags?: string[]): void {\n this._logAndPopTraceOrSpan('on_retriever_error', runId, parentRunId, { err, tags }, err)\n }\n\n public handleAgentAction(action: AgentAction, runId: string, parentRunId?: string, tags?: string[]): void {\n this._logDebugEvent('on_agent_action', runId, parentRunId, { action, tags })\n this._setParentOfRun(runId, parentRunId)\n this._setTraceOrSpanMetadata(null, action, runId, parentRunId)\n }\n\n public handleAgentEnd(action: AgentFinish, runId: string, parentRunId?: string, tags?: string[]): void {\n this._logDebugEvent('on_agent_finish', runId, parentRunId, { action, tags })\n this._popRunAndCaptureTraceOrSpan(runId, parentRunId, action)\n }\n\n // ===== PRIVATE HELPERS =====\n\n private _logAndSetTraceOrSpan(\n eventName: string,\n serialized: Serialized,\n input: any,\n runId: string,\n parentRunId: string | undefined,\n debugPayload: Record<string, unknown>,\n tags?: string[],\n metadata?: Record<string, unknown>,\n runName?: string\n ): void {\n this._logDebugEvent(eventName, runId, parentRunId, debugPayload)\n this._setParentOfRun(runId, parentRunId)\n this._setTraceOrSpanMetadata(serialized, input, runId, parentRunId, metadata, tags, runName)\n }\n\n private _logAndPopTraceOrSpan(\n eventName: string,\n runId: string,\n parentRunId: string | undefined,\n debugPayload: Record<string, unknown>,\n result: any\n ): void {\n this._logDebugEvent(eventName, runId, parentRunId, debugPayload)\n this._popRunAndCaptureTraceOrSpan(runId, parentRunId, result)\n }\n\n private _logAndPopGeneration(\n eventName: string,\n runId: string,\n parentRunId: string | undefined,\n debugPayload: Record<string, unknown>,\n result: LLMResult | Error\n ): void {\n this._logDebugEvent(eventName, runId, parentRunId, debugPayload)\n this._popRunAndCaptureGeneration(runId, parentRunId, result)\n }\n\n private _setParentOfRun(runId: string, parentRunId?: string): void {\n if (parentRunId) {\n this.parentTree[runId] = parentRunId\n }\n }\n\n private _popParentOfRun(runId: string): void {\n delete this.parentTree[runId]\n }\n\n private _findRootRun(runId: string): string {\n let id = runId\n while (this.parentTree[id]) {\n id = this.parentTree[id]\n }\n return id\n }\n\n private _setTraceOrSpanMetadata(\n serialized: any,\n input: any,\n runId: string,\n parentRunId?: string,\n ...args: any[]\n ): void {\n // Use default names if not provided: if this is a top-level run, we mark it as a trace, otherwise as a span.\n const defaultName = parentRunId ? 'span' : 'trace'\n const runName = this._getLangchainRunName(serialized, ...args) || defaultName\n this.runs[runId] = {\n name: runName,\n input,\n startTime: Date.now(),\n } as SpanMetadata\n }\n\n private _setLLMMetadata(\n serialized: Serialized | null,\n runId: string,\n messages: any,\n metadata?: any,\n extraParams?: any,\n runName?: string\n ): void {\n const runNameFound = this._getLangchainRunName(serialized, { extraParams, runName }) || 'generation'\n const generation: GenerationMetadata = {\n name: runNameFound,\n input: sanitizeLangChain(messages),\n startTime: Date.now(),\n }\n if (extraParams) {\n generation.modelParams = getModelParams(extraParams.invocation_params)\n\n if (extraParams.invocation_params && extraParams.invocation_params.tools) {\n generation.tools = extraParams.invocation_params.tools\n }\n }\n if (metadata) {\n if (metadata.ls_model_name) {\n generation.model = metadata.ls_model_name\n }\n if (metadata.ls_provider) {\n generation.provider = metadata.ls_provider\n }\n }\n if (serialized && 'kwargs' in serialized && serialized.kwargs.openai_api_base) {\n generation.baseUrl = serialized.kwargs.openai_api_base\n }\n this.runs[runId] = generation\n }\n\n private _popRunMetadata(runId: string): RunMetadata | undefined {\n const endTime = Date.now()\n const run = this.runs[runId]\n if (!run) {\n console.warn(`No run metadata found for run ${runId}`)\n return undefined\n }\n run.endTime = endTime\n delete this.runs[runId]\n return run\n }\n\n private _getTraceId(runId: string): string {\n return this.traceId ? String(this.traceId) : this._findRootRun(runId)\n }\n\n private _getParentRunId(traceId: string, _runId: string, parentRunId?: string): string | undefined {\n // Replace the parent-run if not found in our stored parent tree.\n if (parentRunId && !this.parentTree[parentRunId]) {\n return traceId\n }\n return parentRunId\n }\n\n private _popRunAndCaptureTraceOrSpan(\n runId: string,\n parentRunId: string | undefined,\n outputs: ChainValues | DocumentInterface[] | AgentFinish | Error | any\n ): void {\n const traceId = this._getTraceId(runId)\n this._popParentOfRun(runId)\n const run = this._popRunMetadata(runId)\n if (!run) {\n return\n }\n if ('modelParams' in run) {\n console.warn(`Run ${runId} is a generation, but attempted to be captured as a trace/span.`)\n return\n }\n const actualParentRunId = this._getParentRunId(traceId, runId, parentRunId)\n this._captureTraceOrSpan(traceId, runId, run as SpanMetadata, outputs, actualParentRunId)\n }\n\n private _captureTraceOrSpan(\n traceId: string,\n runId: string,\n run: SpanMetadata,\n outputs: ChainValues | DocumentInterface[] | AgentFinish | Error | any,\n parentRunId?: string\n ): void {\n const eventName = parentRunId ? '$ai_span' : '$ai_trace'\n const latency = run.endTime ? (run.endTime - run.startTime) / 1000 : 0\n const eventProperties: Record<string, any> = {\n $ai_lib: 'posthog-ai',\n $ai_lib_version: version,\n $ai_trace_id: traceId,\n $ai_input_state: withPrivacyMode(this.client, this.privacyMode, run.input),\n $ai_latency: latency,\n $ai_span_name: run.name,\n $ai_span_id: runId,\n $ai_framework: 'langchain',\n }\n if (parentRunId) {\n eventProperties['$ai_parent_id'] = parentRunId\n }\n\n Object.assign(eventProperties, this.properties)\n if (!this.distinctId) {\n eventProperties['$process_person_profile'] = false\n }\n if (outputs instanceof Error) {\n eventProperties['$ai_error'] = stringifyError(outputs)\n eventProperties['$ai_is_error'] = true\n } else if (outputs !== undefined) {\n eventProperties['$ai_output_state'] = withPrivacyMode(this.client, this.privacyMode, outputs)\n }\n this.client.capture({\n distinctId: this.distinctId ? this.distinctId.toString() : runId,\n event: eventName,\n properties: eventProperties,\n groups: this.groups,\n })\n }\n\n private _popRunAndCaptureGeneration(\n runId: string,\n parentRunId: string | undefined,\n response: LLMResult | Error\n ): void {\n const traceId = this._getTraceId(runId)\n this._popParentOfRun(runId)\n const run = this._popRunMetadata(runId)\n if (!run || typeof run !== 'object' || !('modelParams' in run)) {\n console.warn(`Run ${runId} is not a generation, but attempted to be captured as such.`)\n return\n }\n const actualParentRunId = this._getParentRunId(traceId, runId, parentRunId)\n this._captureGeneration(traceId, runId, run as GenerationMetadata, response, actualParentRunId)\n }\n\n private _captureGeneration(\n traceId: string,\n runId: string,\n run: GenerationMetadata,\n output: LLMResult | Error,\n parentRunId?: string\n ): void {\n const latency = run.endTime ? (run.endTime - run.startTime) / 1000 : 0\n warnIfPostHogAiGateway(run.baseUrl)\n const eventProperties: Record<string, any> = {\n $ai_lib: 'posthog-ai',\n $ai_lib_version: version,\n $ai_trace_id: traceId,\n $ai_span_id: runId,\n $ai_span_name: run.name,\n $ai_parent_id: parentRunId,\n $ai_provider: run.provider,\n $ai_model: run.model,\n $ai_model_parameters: run.modelParams,\n $ai_input: withPrivacyMode(this.client, this.privacyMode, run.input),\n $ai_http_status: 200,\n $ai_latency: latency,\n $ai_base_url: run.baseUrl,\n $ai_framework: 'langchain',\n }\n\n if (run.tools) {\n eventProperties['$ai_tools'] = run.tools\n }\n\n if (output instanceof Error) {\n eventProperties['$ai_http_status'] = (output as any).status || 500\n eventProperties['$ai_error'] = stringifyError(output)\n eventProperties['$ai_is_error'] = true\n } else {\n // Handle token usage\n const [inputTokens, outputTokens, additionalTokenData] = this.parseUsage(output, run.provider, run.model)\n eventProperties['$ai_input_tokens'] = inputTokens\n eventProperties['$ai_output_tokens'] = outputTokens\n\n // Add additional token data to properties\n if (additionalTokenData.cacheReadInputTokens) {\n eventProperties['$ai_cache_read_input_tokens'] = additionalTokenData.cacheReadInputTokens\n }\n if (additionalTokenData.cacheWriteInputTokens) {\n eventProperties['$ai_cache_creation_input_tokens'] = additionalTokenData.cacheWriteInputTokens\n }\n if (additionalTokenData.reasoningTokens) {\n eventProperties['$ai_reasoning_tokens'] = additionalTokenData.reasoningTokens\n }\n if (additionalTokenData.webSearchCount !== undefined) {\n eventProperties['$ai_web_search_count'] = additionalTokenData.webSearchCount\n }\n\n // Extract stop reason from generation info\n const stopReason = this._extractStopReason(output)\n if (stopReason) {\n eventProperties['$ai_stop_reason'] = stopReason\n }\n\n // Handle generations/completions\n let completions\n if (output.generations && Array.isArray(output.generations)) {\n const lastGeneration = output.generations[output.generations.length - 1]\n if (Array.isArray(lastGeneration) && lastGeneration.length > 0) {\n // Check if this is a ChatGeneration by looking at the first item\n const isChatGeneration = 'message' in lastGeneration[0] && lastGeneration[0].message\n\n if (isChatGeneration) {\n // For ChatGeneration, convert messages to dict format\n completions = lastGeneration.map((gen: any) => {\n return this._convertMessageToDict(gen.message)\n })\n } else {\n // For n