@posthog/ai
Version:
PostHog Node.js AI integrations
509 lines (504 loc) • 16.1 kB
JavaScript
import { experimental_wrapLanguageModel } from 'ai';
import { v4 } from 'uuid';
import { Buffer } from 'buffer';
// limit large outputs by truncating to 200kb (approx 200k bytes)
const MAX_OUTPUT_SIZE = 200000;
const STRING_FORMAT = 'utf8';
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'];
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;
};
const truncate = str => {
try {
const buffer = Buffer.from(str, STRING_FORMAT);
if (buffer.length <= MAX_OUTPUT_SIZE) {
return str;
}
const truncatedBuffer = buffer.slice(0, MAX_OUTPUT_SIZE);
return `${truncatedBuffer.toString(STRING_FORMAT)}... [truncated]`;
} catch (error) {
console.error('Error truncating, likely not a string');
return str;
}
};
function sanitizeValues(obj) {
if (obj === undefined || obj === null) {
return obj;
}
const jsonSafe = JSON.parse(JSON.stringify(obj));
if (typeof jsonSafe === 'string') {
return Buffer.from(jsonSafe, STRING_FORMAT).toString(STRING_FORMAT);
} 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 sendEventToPosthog = async ({
client,
distinctId,
traceId,
model,
provider,
input,
output,
latency,
baseURL,
params,
httpStatus = 200,
usage = {},
isError = false,
error,
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 (isError) {
errorData = {
$ai_is_error: true,
$ai_error: safeError
};
}
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
} : {})
};
const properties = {
$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,
$ai_output_tokens: usage.outputTokens ?? 0,
...additionalTokenValues,
$ai_latency: latency,
$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: '$ai_generation',
properties,
groups: params.posthogGroups
};
if (captureImmediate) {
// await capture promise to send single event in serverless environments
await client.captureImmediate(event);
} else {
client.capture(event);
}
};
const mapVercelParams = params => {
return {
temperature: params.temperature,
max_tokens: params.maxTokens,
top_p: params.topP,
frequency_penalty: params.frequencyPenalty,
presence_penalty: params.presencePenalty,
stop: params.stopSequences,
stream: params.stream
};
};
const mapVercelPrompt = prompt => {
// normalize single inputs into an array of messages
let promptsArray;
if (typeof prompt === 'string') {
promptsArray = [{
role: 'user',
content: prompt
}];
} else if (!Array.isArray(prompt)) {
promptsArray = [prompt];
} else {
promptsArray = prompt;
}
// Map and truncate individual content
const inputs = promptsArray.map(p => {
let content = {};
if (Array.isArray(p.content)) {
content = p.content.map(c => {
if (c.type === 'text') {
return {
type: 'text',
content: truncate(c.text)
};
} else if (c.type === 'image') {
return {
type: 'image',
content: {
// if image is a url use it, or use "none supported"
image: c.image instanceof URL ? c.image.toString() : 'raw images not supported',
mimeType: c.mimeType
}
};
} else if (c.type === 'file') {
return {
type: 'file',
content: {
file: c.data instanceof URL ? c.data.toString() : 'raw files not supported',
mimeType: c.mimeType
}
};
} else if (c.type === 'tool-call') {
return {
type: 'tool-call',
content: {
toolCallId: c.toolCallId,
toolName: c.toolName,
args: c.args
}
};
} else if (c.type === 'tool-result') {
return {
type: 'tool-result',
content: {
toolCallId: c.toolCallId,
toolName: c.toolName,
result: c.result,
isError: c.isError
}
};
}
return {
content: ''
};
});
} else {
content = {
type: 'text',
text: truncate(p.content)
};
}
return {
role: p.role,
content
};
});
try {
// Trim the inputs array until its JSON size fits within MAX_OUTPUT_SIZE
let serialized = JSON.stringify(inputs);
let removedCount = 0;
// We need to keep track of the initial size of the inputs array because we're going to be mutating it
const initialSize = inputs.length;
for (let i = 0; i < initialSize && Buffer.byteLength(serialized, 'utf8') > MAX_OUTPUT_SIZE; i++) {
inputs.shift();
removedCount++;
serialized = JSON.stringify(inputs);
}
if (removedCount > 0) {
// 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 => {
// normalize string results to object
const normalizedResult = typeof result === 'string' ? {
text: result
} : result;
const output = {
...(normalizedResult.text ? {
text: normalizedResult.text
} : {}),
...(normalizedResult.object ? {
object: normalizedResult.object
} : {}),
...(normalizedResult.reasoning ? {
reasoning: normalizedResult.reasoning
} : {}),
...(normalizedResult.response ? {
response: normalizedResult.response
} : {}),
...(normalizedResult.finishReason ? {
finishReason: normalizedResult.finishReason
} : {}),
...(normalizedResult.usage ? {
usage: normalizedResult.usage
} : {}),
...(normalizedResult.warnings ? {
warnings: normalizedResult.warnings
} : {}),
...(normalizedResult.providerMetadata ? {
toolCalls: normalizedResult.providerMetadata
} : {}),
...(normalizedResult.files ? {
files: normalizedResult.files.map(file => ({
name: file.name,
size: file.size,
type: file.type
}))
} : {})
};
if (output.text && !output.object && !output.reasoning) {
return [{
content: truncate(output.text),
role: 'assistant'
}];
}
// otherwise stringify and truncate
try {
const jsonOutput = JSON.stringify(output);
return [{
content: truncate(jsonOutput),
role: 'assistant'
}];
} catch (error) {
console.error('Error stringifying output');
return [];
}
};
const extractProvider = model => {
const provider = model.provider.toLowerCase();
const providerName = provider.split('.')[0];
return providerName;
};
const createInstrumentationMiddleware = (phClient, model, options) => {
const middleware = {
wrapGenerate: async ({
doGenerate,
params
}) => {
const startTime = Date.now();
const mergedParams = {
...options,
...mapVercelParams(params)
};
try {
const result = await doGenerate();
const latency = (Date.now() - startTime) / 1000;
const modelId = options.posthogModelOverride ?? (result.response?.modelId ? result.response.modelId : model.modelId);
const provider = options.posthogProviderOverride ?? extractProvider(model);
const baseURL = ''; // cannot currently get baseURL from vercel
const content = mapVercelOutput(result);
// let tools = result.toolCalls
const providerMetadata = result.providerMetadata;
const additionalTokenValues = {
...(providerMetadata?.openai?.reasoningTokens ? {
reasoningTokens: providerMetadata.openai.reasoningTokens
} : {}),
...(providerMetadata?.openai?.cachedPromptTokens ? {
cacheReadInputTokens: providerMetadata.openai.cachedPromptTokens
} : {}),
...(providerMetadata?.anthropic ? {
cacheReadInputTokens: providerMetadata.anthropic.cacheReadInputTokens,
cacheCreationInputTokens: providerMetadata.anthropic.cacheCreationInputTokens
} : {})
};
await sendEventToPosthog({
client: phClient,
distinctId: options.posthogDistinctId,
traceId: options.posthogTraceId,
model: modelId,
provider: provider,
input: options.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt),
output: [{
content,
role: 'assistant'
}],
latency,
baseURL,
params: mergedParams,
httpStatus: 200,
usage: {
inputTokens: result.usage.promptTokens,
outputTokens: result.usage.completionTokens,
...additionalTokenValues
},
captureImmediate: options.posthogCaptureImmediate
});
return result;
} catch (error) {
const modelId = model.modelId;
await sendEventToPosthog({
client: phClient,
distinctId: options.posthogDistinctId,
traceId: options.posthogTraceId,
model: modelId,
provider: model.provider,
input: options.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt),
output: [],
latency: 0,
baseURL: '',
params: mergedParams,
httpStatus: error?.status ? error.status : 500,
usage: {
inputTokens: 0,
outputTokens: 0
},
isError: true,
error: truncate(JSON.stringify(error)),
captureImmediate: options.posthogCaptureImmediate
});
throw error;
}
},
wrapStream: async ({
doStream,
params
}) => {
const startTime = Date.now();
let generatedText = '';
let usage = {};
const mergedParams = {
...options,
...mapVercelParams(params)
};
const modelId = options.posthogModelOverride ?? model.modelId;
const provider = options.posthogProviderOverride ?? extractProvider(model);
const baseURL = ''; // cannot currently get baseURL from vercel
try {
const {
stream,
...rest
} = await doStream();
const transformStream = new TransformStream({
transform(chunk, controller) {
if (chunk.type === 'text-delta') {
generatedText += chunk.textDelta;
}
if (chunk.type === 'finish') {
usage = {
inputTokens: chunk.usage?.promptTokens,
outputTokens: chunk.usage?.completionTokens
};
if (chunk.providerMetadata?.openai?.reasoningTokens) {
usage.reasoningTokens = chunk.providerMetadata.openai.reasoningTokens;
}
if (chunk.providerMetadata?.openai?.cachedPromptTokens) {
usage.cacheReadInputTokens = chunk.providerMetadata.openai.cachedPromptTokens;
}
if (chunk.providerMetadata?.anthropic?.cacheReadInputTokens) {
usage.cacheReadInputTokens = chunk.providerMetadata.anthropic.cacheReadInputTokens;
}
if (chunk.providerMetadata?.anthropic?.cacheCreationInputTokens) {
usage.cacheCreationInputTokens = chunk.providerMetadata.anthropic.cacheCreationInputTokens;
}
}
controller.enqueue(chunk);
},
flush: async () => {
const latency = (Date.now() - startTime) / 1000;
await sendEventToPosthog({
client: phClient,
distinctId: options.posthogDistinctId,
traceId: options.posthogTraceId,
model: modelId,
provider: provider,
input: options.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt),
output: [{
content: generatedText,
role: 'assistant'
}],
latency,
baseURL,
params: mergedParams,
httpStatus: 200,
usage,
captureImmediate: options.posthogCaptureImmediate
});
}
});
return {
stream: stream.pipeThrough(transformStream),
...rest
};
} catch (error) {
await sendEventToPosthog({
client: phClient,
distinctId: options.posthogDistinctId,
traceId: options.posthogTraceId,
model: modelId,
provider: provider,
input: options.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt),
output: [],
latency: 0,
baseURL: '',
params: mergedParams,
httpStatus: error?.status ? error.status : 500,
usage: {
inputTokens: 0,
outputTokens: 0
},
isError: true,
error: truncate(JSON.stringify(error)),
captureImmediate: options.posthogCaptureImmediate
});
throw error;
}
}
};
return middleware;
};
const wrapVercelLanguageModel = (model, phClient, options) => {
const traceId = options.posthogTraceId ?? v4();
const middleware = createInstrumentationMiddleware(phClient, model, {
...options,
posthogTraceId: traceId,
posthogDistinctId: options.posthogDistinctId ?? traceId
});
const wrappedModel = experimental_wrapLanguageModel({
model,
middleware
});
return wrappedModel;
};
export { wrapVercelLanguageModel as withTracing };
//# sourceMappingURL=index.mjs.map