UNPKG

@posthog/ai

Version:
841 lines (813 loc) 26.3 kB
import { GoogleGenAI } from '@google/genai'; import { v4 } from 'uuid'; import { uuidv7 } from '@posthog/core'; var version = "7.9.2"; // Type guards for safer type checking const isString = value => { return typeof value === 'string'; }; const isObject = value => { return value !== null && typeof value === 'object' && !Array.isArray(value); }; const REDACTED_IMAGE_PLACEHOLDER = '[base64 image redacted]'; // ============================================ // Multimodal Feature Toggle // ============================================ const isMultimodalEnabled = () => { const val = process.env._INTERNAL_LLMA_MULTIMODAL || ''; return val.toLowerCase() === 'true' || val === '1' || val.toLowerCase() === 'yes'; }; // ============================================ // Base64 Detection Helpers // ============================================ const isBase64DataUrl = str => { return /^data:([^;]+);base64,/.test(str); }; const isValidUrl = str => { try { new URL(str); return true; } catch { // Not an absolute URL, check if it's a relative URL or path return str.startsWith('/') || str.startsWith('./') || str.startsWith('../'); } }; const isRawBase64 = str => { // Skip if it's a valid URL or path if (isValidUrl(str)) { return false; } // Check if it's a valid base64 string // Base64 images are typically at least a few hundred chars, but we'll be conservative return str.length > 20 && /^[A-Za-z0-9+/]+=*$/.test(str); }; function redactBase64DataUrl(str) { if (isMultimodalEnabled()) return str; if (!isString(str)) return str; // Check for data URL format if (isBase64DataUrl(str)) { return REDACTED_IMAGE_PLACEHOLDER; } // Check for raw base64 (Vercel sends raw base64 for inline images) if (isRawBase64(str)) { return REDACTED_IMAGE_PLACEHOLDER; } return str; } const sanitizeGeminiPart = part => { if (isMultimodalEnabled()) return part; if (!isObject(part)) return part; // Handle Gemini's inline data format (images, audio, PDFs all use inlineData) if ('inlineData' in part && isObject(part.inlineData) && 'data' in part.inlineData) { return { ...part, inlineData: { ...part.inlineData, data: REDACTED_IMAGE_PLACEHOLDER } }; } return part; }; const processGeminiItem = item => { if (!isObject(item)) return item; // If it has parts, process them if ('parts' in item && item.parts) { const parts = Array.isArray(item.parts) ? item.parts.map(sanitizeGeminiPart) : sanitizeGeminiPart(item.parts); return { ...item, parts }; } return item; }; const sanitizeGemini = data => { // Gemini has a different structure with 'parts' directly on items instead of 'content' // So we need custom processing instead of using processMessages if (!data) return data; if (Array.isArray(data)) { return data.map(processGeminiItem); } return processGeminiItem(data); }; /** * 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 formatResponseGemini = response => { const output = []; if (response.candidates && Array.isArray(response.candidates)) { for (const candidate of response.candidates) { if (candidate.content && candidate.content.parts) { const content = []; for (const part of candidate.content.parts) { if (part.text) { content.push({ type: 'text', text: part.text }); } else if (part.functionCall) { content.push({ type: 'function', function: { name: part.functionCall.name, arguments: part.functionCall.args } }); } else if (part.inlineData) { // Handle audio/media inline data const mimeType = part.inlineData.mimeType || 'audio/pcm'; let data = part.inlineData.data; // Handle binary data (Uint8Array/Buffer -> base64) if (data instanceof Uint8Array) { if (typeof Buffer !== 'undefined') { data = Buffer.from(data).toString('base64'); } else { let binary = ''; for (let i = 0; i < data.length; i++) { binary += String.fromCharCode(data[i]); } data = btoa(binary); } } // Sanitize base64 data for images and other large inline data data = redactBase64DataUrl(data); content.push({ type: 'audio', mime_type: mimeType, data: data }); } } if (content.length > 0) { output.push({ role: 'assistant', content }); } } else if (candidate.text) { output.push({ role: 'assistant', content: [{ type: 'text', text: candidate.text }] }); } } } else if (response.text) { output.push({ role: 'assistant', content: [{ type: 'text', text: response.text }] }); } return output; }; const withPrivacyMode = (client, privacyMode, input) => { return client.privacy_mode || privacyMode ? null : input; }; /** * 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.config && params.config.tools) { return params.config.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; } const POSTHOG_PARAMS_MAP = { posthogDistinctId: 'distinctId', posthogTraceId: 'traceId', posthogProperties: 'properties', posthogPrivacyMode: 'privacyMode', posthogGroups: 'groups', posthogModelOverride: 'modelOverride', posthogProviderOverride: 'providerOverride', posthogCostOverride: 'costOverride', posthogCaptureImmediate: 'captureImmediate' }; function extractPosthogParams(body) { const providerParams = {}; const posthogParams = {}; for (const [key, value] of Object.entries(body)) { if (POSTHOG_PARAMS_MAP[key]) { posthogParams[POSTHOG_PARAMS_MAP[key]] = value; } else if (key.startsWith('posthog')) { console.warn(`Unknown Posthog parameter ${key}`); } else { providerParams[key] = value; } } return { providerParams: providerParams, posthogParams: addDefaults(posthogParams) }; } function addDefaults(params) { return { ...params, privacyMode: params.privacyMode ?? false, traceId: params.traceId ?? v4() }; } const sendEventWithErrorToPosthog = async ({ client, traceId, error, ...args }) => { const httpStatus = error && typeof error === 'object' && 'status' in error ? error.status ?? 500 : 500; const properties = { client, traceId, httpStatus, error: JSON.stringify(error), ...args }; const enrichedError = error; if (client.options?.enableExceptionAutocapture) { // assign a uuid that can be used to link the trace and exception events const exceptionId = uuidv7(); client.captureException(error, undefined, { $ai_trace_id: traceId }, exceptionId); enrichedError.__posthog_previously_captured_error = true; properties.exceptionId = exceptionId; } await sendEventToPosthog(properties); return enrichedError; }; const sendEventToPosthog = async ({ client, eventType = AIEvent.Generation, distinctId, traceId, model, provider, input, output, latency, timeToFirstToken, baseURL, params, httpStatus = 200, usage = {}, error, exceptionId, tools, captureImmediate = false }) => { if (!client.capture) { return Promise.resolve(); } // sanitize input and output for UTF-8 validity const safeInput = sanitizeValues(input); const safeOutput = sanitizeValues(output); const safeError = sanitizeValues(error); let errorData = {}; if (error) { errorData = { $ai_is_error: true, $ai_error: safeError, $exception_event_id: exceptionId }; } let costOverrideData = {}; if (params.posthogCostOverride) { const inputCostUSD = (params.posthogCostOverride.inputCost ?? 0) * (usage.inputTokens ?? 0); const outputCostUSD = (params.posthogCostOverride.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: params.posthogProviderOverride ?? provider, $ai_model: params.posthogModelOverride ?? model, $ai_model_parameters: getModelParams(params), $ai_input: withPrivacyMode(client, params.posthogPrivacyMode ?? false, safeInput), $ai_output_choices: withPrivacyMode(client, params.posthogPrivacyMode ?? false, safeOutput), $ai_http_status: httpStatus, $ai_input_tokens: usage.inputTokens ?? 0, ...(usage.outputTokens !== undefined ? { $ai_output_tokens: usage.outputTokens } : {}), ...additionalTokenValues, $ai_latency: latency, ...(timeToFirstToken !== undefined ? { $ai_time_to_first_token: timeToFirstToken } : {}), $ai_trace_id: traceId, $ai_base_url: baseURL, ...params.posthogProperties, ...(distinctId ? {} : { $process_person_profile: false }), ...(tools ? { $ai_tools: tools } : {}), ...errorData, ...costOverrideData }; const event = { distinctId: distinctId ?? traceId, event: eventType, properties, groups: params.posthogGroups }; if (captureImmediate) { // await capture promise to send single event in serverless environments await client.captureImmediate(event); } else { client.capture(event); } return Promise.resolve(); }; class PostHogGoogleGenAI { constructor(config) { const { posthog, ...geminiConfig } = config; this.phClient = posthog; this.client = new GoogleGenAI(geminiConfig); this.models = new WrappedModels(this.client, this.phClient); } } class WrappedModels { constructor(client, phClient) { this.client = client; this.phClient = phClient; } async generateContent(params) { const { providerParams: geminiParams, posthogParams } = extractPosthogParams(params); const startTime = Date.now(); try { const response = await this.client.models.generateContent(geminiParams); const latency = (Date.now() - startTime) / 1000; const availableTools = extractAvailableToolCalls('gemini', geminiParams); const metadata = response.usageMetadata; await sendEventToPosthog({ client: this.phClient, ...posthogParams, model: geminiParams.model, provider: 'gemini', input: this.formatInputForPostHog(geminiParams), output: formatResponseGemini(response), latency, baseURL: 'https://generativelanguage.googleapis.com', params: params, httpStatus: 200, usage: { inputTokens: metadata?.promptTokenCount ?? 0, outputTokens: metadata?.candidatesTokenCount ?? 0, reasoningTokens: metadata?.thoughtsTokenCount ?? 0, cacheReadInputTokens: metadata?.cachedContentTokenCount ?? 0, webSearchCount: calculateGoogleWebSearchCount(response), rawUsage: metadata }, tools: availableTools }); return response; } catch (error) { const latency = (Date.now() - startTime) / 1000; const enrichedError = await sendEventWithErrorToPosthog({ client: this.phClient, ...posthogParams, model: geminiParams.model, provider: 'gemini', input: this.formatInputForPostHog(geminiParams), output: [], latency, baseURL: 'https://generativelanguage.googleapis.com', params: params, usage: { inputTokens: 0, outputTokens: 0 }, error: error }); throw enrichedError; } } async *generateContentStream(params) { const { providerParams: geminiParams, posthogParams } = extractPosthogParams(params); const startTime = Date.now(); const accumulatedContent = []; let firstTokenTime; let usage = { inputTokens: 0, outputTokens: 0, webSearchCount: 0, rawUsage: undefined }; try { const stream = await this.client.models.generateContentStream(geminiParams); for await (const chunk of stream) { // Track first token time when we get text content if (firstTokenTime === undefined && chunk.text) { firstTokenTime = Date.now(); } const chunkWebSearchCount = calculateGoogleWebSearchCount(chunk); if (chunkWebSearchCount > 0 && chunkWebSearchCount > (usage.webSearchCount ?? 0)) { usage.webSearchCount = chunkWebSearchCount; } // Handle text content if (chunk.text) { // Find if we already have a text item to append to let lastTextItem; for (let i = accumulatedContent.length - 1; i >= 0; i--) { if (accumulatedContent[i].type === 'text') { lastTextItem = accumulatedContent[i]; break; } } if (lastTextItem && lastTextItem.type === 'text') { lastTextItem.text += chunk.text; } else { accumulatedContent.push({ type: 'text', text: chunk.text }); } } // Handle function calls from candidates if (chunk.candidates && Array.isArray(chunk.candidates)) { for (const candidate of chunk.candidates) { if (candidate.content && candidate.content.parts) { for (const part of candidate.content.parts) { // Type-safe check for functionCall if ('functionCall' in part) { if (firstTokenTime === undefined) { firstTokenTime = Date.now(); } const funcCall = part.functionCall; if (funcCall?.name) { accumulatedContent.push({ type: 'function', function: { name: funcCall.name, arguments: funcCall.args || {} } }); } } } } } } // Update usage metadata - handle both old and new field names if (chunk.usageMetadata) { const metadata = chunk.usageMetadata; usage = { inputTokens: metadata.promptTokenCount ?? 0, outputTokens: metadata.candidatesTokenCount ?? 0, reasoningTokens: metadata.thoughtsTokenCount ?? 0, cacheReadInputTokens: metadata.cachedContentTokenCount ?? 0, webSearchCount: usage.webSearchCount, rawUsage: metadata }; } yield chunk; } const latency = (Date.now() - startTime) / 1000; const timeToFirstToken = firstTokenTime !== undefined ? (firstTokenTime - startTime) / 1000 : undefined; const availableTools = extractAvailableToolCalls('gemini', geminiParams); // Format output similar to formatResponseGemini const output = accumulatedContent.length > 0 ? [{ role: 'assistant', content: accumulatedContent }] : []; await sendEventToPosthog({ client: this.phClient, ...posthogParams, model: geminiParams.model, provider: 'gemini', input: this.formatInputForPostHog(geminiParams), output, latency, timeToFirstToken, baseURL: 'https://generativelanguage.googleapis.com', params: params, httpStatus: 200, usage: { ...usage, webSearchCount: usage.webSearchCount, rawUsage: usage.rawUsage }, tools: availableTools }); } catch (error) { const latency = (Date.now() - startTime) / 1000; const enrichedError = await sendEventWithErrorToPosthog({ client: this.phClient, ...posthogParams, model: geminiParams.model, provider: 'gemini', input: this.formatInputForPostHog(geminiParams), output: [], latency, baseURL: 'https://generativelanguage.googleapis.com', params: params, usage: { inputTokens: 0, outputTokens: 0 }, error: error }); throw enrichedError; } } formatPartsAsContentBlocks(parts) { const blocks = []; for (const part of parts) { // Handle dict/object with text field if (part && typeof part === 'object' && 'text' in part && part.text) { blocks.push({ type: 'text', text: String(part.text) }); } // Handle string parts else if (typeof part === 'string') { blocks.push({ type: 'text', text: part }); } // Handle inlineData (images, audio, PDFs) else if (part && typeof part === 'object' && 'inlineData' in part) { const inlineData = part.inlineData; const mimeType = inlineData.mimeType || inlineData.mime_type || ''; const contentType = mimeType.startsWith('image/') ? 'image' : 'document'; blocks.push({ type: contentType, inline_data: { data: inlineData.data, mime_type: mimeType } }); } } return blocks; } formatInput(contents) { if (typeof contents === 'string') { return [{ role: 'user', content: contents }]; } if (Array.isArray(contents)) { return contents.map(item => { if (typeof item === 'string') { return { role: 'user', content: item }; } if (item && typeof item === 'object') { const obj = item; if ('text' in obj && obj.text) { return { role: isString(obj.role) ? obj.role : 'user', content: obj.text }; } if ('content' in obj && obj.content) { // If content is a list, format it as content blocks if (Array.isArray(obj.content)) { const contentBlocks = this.formatPartsAsContentBlocks(obj.content); return { role: isString(obj.role) ? obj.role : 'user', content: contentBlocks }; } return { role: isString(obj.role) ? obj.role : 'user', content: obj.content }; } if ('parts' in obj && Array.isArray(obj.parts)) { const contentBlocks = this.formatPartsAsContentBlocks(obj.parts); return { role: isString(obj.role) ? obj.role : 'user', content: contentBlocks }; } } return { role: 'user', content: toContentString(item) }; }); } if (contents && typeof contents === 'object') { const obj = contents; if ('text' in obj && obj.text) { return [{ role: 'user', content: obj.text }]; } if ('content' in obj && obj.content) { return [{ role: 'user', content: obj.content }]; } } return [{ role: 'user', content: toContentString(contents) }]; } extractSystemInstruction(params) { if (!params || typeof params !== 'object' || !params.config) { return null; } const config = params.config; if (!('systemInstruction' in config)) { return null; } const systemInstruction = config.systemInstruction; if (typeof systemInstruction === 'string') { return systemInstruction; } if (systemInstruction && typeof systemInstruction === 'object' && 'text' in systemInstruction) { return systemInstruction.text; } if (systemInstruction && typeof systemInstruction === 'object' && 'parts' in systemInstruction && Array.isArray(systemInstruction.parts)) { for (const part of systemInstruction.parts) { if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') { return part.text; } } } if (Array.isArray(systemInstruction)) { for (const part of systemInstruction) { if (typeof part === 'string') { return part; } if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') { return part.text; } } } return null; } formatInputForPostHog(params) { const sanitized = sanitizeGemini(params.contents); const messages = this.formatInput(sanitized); const systemInstruction = this.extractSystemInstruction(params); if (systemInstruction) { const hasSystemMessage = messages.some(msg => msg.role === 'system'); if (!hasSystemMessage) { return [{ role: 'system', content: systemInstruction }, ...messages]; } } return messages; } } /** * Detect if Google Search grounding was used in the response. * Gemini bills per request that uses grounding, not per individual query. * Returns 1 if grounding was used, 0 otherwise. */ function calculateGoogleWebSearchCount(response) { if (!response || typeof response !== 'object' || !('candidates' in response)) { return 0; } const candidates = response.candidates; if (!Array.isArray(candidates)) { return 0; } const hasGrounding = candidates.some(candidate => { if (!candidate || typeof candidate !== 'object') { return false; } // Check for grounding metadata if ('groundingMetadata' in candidate && candidate.groundingMetadata) { const metadata = candidate.groundingMetadata; if (typeof metadata === 'object') { // Check if web_search_queries exists and is non-empty if ('webSearchQueries' in metadata && Array.isArray(metadata.webSearchQueries) && metadata.webSearchQueries.length > 0) { return true; } // Check if grounding_chunks exists and is non-empty if ('groundingChunks' in metadata && Array.isArray(metadata.groundingChunks) && metadata.groundingChunks.length > 0) { return true; } } } // Check for google search in function calls if ('content' in candidate && candidate.content && typeof candidate.content === 'object') { const content = candidate.content; if ('parts' in content && Array.isArray(content.parts)) { return content.parts.some(part => { if (!part || typeof part !== 'object' || !('functionCall' in part)) { return false; } const functionCall = part.functionCall; if (functionCall && typeof functionCall === 'object' && 'name' in functionCall && typeof functionCall.name === 'string') { return functionCall.name.includes('google_search') || functionCall.name.includes('grounding'); } return false; }); } } return false; }); return hasGrounding ? 1 : 0; } export { PostHogGoogleGenAI as Gemini, PostHogGoogleGenAI, WrappedModels, PostHogGoogleGenAI as default }; //# sourceMappingURL=index.mjs.map