@posthog/ai
Version:
PostHog Node.js AI integrations
841 lines (813 loc) • 26.3 kB
JavaScript
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