@posthog/ai
Version:
PostHog Node.js AI integrations
730 lines (709 loc) • 25.6 kB
JavaScript
import AnthropicOriginal from '@anthropic-ai/sdk';
import { v4 } from 'uuid';
import { uuidv7 } from '@posthog/core';
const DATA_URL_PREFIX_RE = /^data:([^;,\s]+)(?:;[^;,\s]+)*;base64,/i;
const BASE64_ALPHABET_RE = /^[A-Za-z0-9+/_=-]+$/;
class Base64Recognizer {
recognize(value, minLength) {
const dataUrl = DATA_URL_PREFIX_RE.exec(value);
if (dataUrl) return {
kind: 'data-url',
mediaType: dataUrl[1]
};
if (value.length < minLength) return {
kind: 'none'
};
const confidencePrefix = value.slice(0, minLength);
if (BASE64_ALPHABET_RE.test(confidencePrefix)) {
return {
kind: 'raw'
};
} else {
return {
kind: 'none'
};
}
}
}
const MIME_HINT_KEYS = ['mediaType', 'media_type', 'mimeType', 'mime_type'];
const STRONG_CONTEXT_KEYS = new Set(['data', 'file_data', 'fileData', 'image_url', 'imageUrl', 'video_url', 'videoUrl', 'audio', 'audio_data', 'audioData', 'inline_data', 'inlineData', 'source', 'result']);
const STRONG_CONTEXT_TYPES = new Set(['image', 'image_url', 'input_image', 'audio', 'input_audio', 'video', 'video_url', 'file', 'input_file', 'document', 'media', 'file-data']);
const FILE_FAMILY_TYPES = new Set(['file', 'input_file', 'document', 'media', 'file-data']);
const KNOWN_AUDIO_FORMATS = new Set(['wav', 'mp3', 'ogg', 'flac', 'm4a', 'aac', 'webm']);
class MediaTypeContext {
static EMPTY = new MediaTypeContext(undefined, undefined);
constructor(parent, key) {
this.parent = parent;
this.key = key;
}
inferMediaType() {
return this.inferFromSiblingMime() ?? this.inferFromSiblingFormat() ?? this.inferFromParentType() ?? this.inferFromKey();
}
inferFromSiblingMime() {
if (!this.parent) return undefined;
for (const hint of MIME_HINT_KEYS) {
const v = this.parent[hint];
if (typeof v === 'string') return v;
}
return undefined;
}
inferFromSiblingFormat() {
if (!this.parent) return undefined;
const fmt = this.parent.format;
if (typeof fmt === 'string' && KNOWN_AUDIO_FORMATS.has(fmt.toLowerCase())) {
return `audio/${fmt.toLowerCase()}`;
}
return undefined;
}
inferFromParentType() {
if (!this.parent) return undefined;
const t = this.parent.type;
if (typeof t !== 'string') return undefined;
if (t === 'image' || t === 'image_url' || t === 'input_image') return 'image';
if (t === 'audio' || t === 'input_audio') return 'audio';
if (t === 'video' || t === 'video_url') return 'video';
if (FILE_FAMILY_TYPES.has(t)) return 'application/octet-stream';
return undefined;
}
inferFromKey() {
if (!this.key) return undefined;
const key = this.key.toLowerCase();
if (key.includes('audio')) return 'audio';
if (key.includes('video')) return 'video';
if (key.includes('image')) return 'image';
if (key.includes('file') || key.includes('document')) return 'application/octet-stream';
return undefined;
}
signalsBinary() {
if (this.parent) {
for (const hint of MIME_HINT_KEYS) {
if (typeof this.parent[hint] === 'string') return true;
}
const fmt = this.parent.format;
if (typeof fmt === 'string' && KNOWN_AUDIO_FORMATS.has(fmt.toLowerCase())) return true;
const t = this.parent.type;
if (typeof t === 'string' && STRONG_CONTEXT_TYPES.has(t)) return true;
}
if (this.key && STRONG_CONTEXT_KEYS.has(this.key)) return true;
return false;
}
}
const STRONG_CONTEXT_MIN_LENGTH = 64;
const WEAK_CONTEXT_MIN_LENGTH = 1024;
class BinaryContentRedactor {
visited = new WeakSet();
constructor(recognizer = new Base64Recognizer()) {
this.recognizer = recognizer;
}
redact(value) {
if (this.isMultimodalEnabled()) return value;
this.visited = new WeakSet();
return this.walk(value, MediaTypeContext.EMPTY);
}
walk(value, ctx) {
if (value === null || value === undefined) return value;
if (typeof value === 'string') return this.redactString(value, ctx);
if (typeof value !== 'object') return value;
// Buffer extends Uint8Array, so this branch catches both.
if (typeof Uint8Array !== 'undefined' && value instanceof Uint8Array) {
return this.placeholderFor(ctx.inferMediaType());
}
if (this.visited.has(value)) return null;
this.visited.add(value);
if (Array.isArray(value)) {
return value.map(item => this.walk(item, ctx));
}
const obj = value;
const out = {};
for (const k of Object.keys(obj)) {
out[k] = this.walk(obj[k], new MediaTypeContext(obj, k));
}
return out;
}
redactString(value, ctx) {
const minLength = ctx.signalsBinary() ? STRONG_CONTEXT_MIN_LENGTH : WEAK_CONTEXT_MIN_LENGTH;
const recognition = this.recognizer.recognize(value, minLength);
switch (recognition.kind) {
case 'data-url':
return this.placeholderFor(recognition.mediaType);
case 'raw':
return this.placeholderFor(ctx.inferMediaType());
case 'none':
return value;
}
}
placeholderFor(mediaType) {
if (!mediaType) return '[base64 redacted]';
if (mediaType === 'application/octet-stream') return '[base64 file redacted]';
return `[base64 ${mediaType} redacted]`;
}
isMultimodalEnabled() {
const val = process.env._INTERNAL_LLMA_MULTIMODAL || '';
return val.toLowerCase() === 'true' || val === '1' || val.toLowerCase() === 'yes';
}
}
const redactor = new BinaryContentRedactor();
const sanitizeAnthropic = data => redactor.redact(data);
const TOKEN_PROPERTY_KEYS = new Set(['$ai_input_tokens', '$ai_output_tokens', '$ai_cache_read_input_tokens', '$ai_cache_creation_input_tokens', '$ai_total_tokens', '$ai_reasoning_tokens']);
function getTokensSource(posthogProperties) {
if (posthogProperties && Object.keys(posthogProperties).some(key => TOKEN_PROPERTY_KEYS.has(key))) {
return 'passthrough';
}
return 'sdk';
}
const STRING_FORMAT = 'utf8';
// Reused across calls to avoid per-invocation allocation; truncate() runs
// hundreds of times for prompts with many parts.
new TextEncoder();
new TextDecoder(STRING_FORMAT, {
fatal: false
});
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 formatResponseAnthropic = response => {
const output = [];
const content = [];
for (const choice of response.content ?? []) {
if (choice?.type === 'text' && choice?.text) {
content.push({
type: 'text',
text: choice.text
});
} else if (choice?.type === 'tool_use' && choice?.name && choice?.id) {
content.push({
type: 'function',
id: choice.id,
function: {
name: choice.name,
arguments: choice.input || {}
}
});
}
}
if (content.length > 0) {
output.push({
role: 'assistant',
content
});
}
return output;
};
const mergeSystemPrompt = (params, provider) => {
{
const messages = params.messages || [];
if (!params.system) {
return messages;
}
const systemMessage = params.system;
return [{
role: 'system',
content: systemMessage
}, ...messages];
}
};
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.tools) {
return params.tools;
}
return null;
}
};
let AIEvent = /*#__PURE__*/function (AIEvent) {
AIEvent["Generation"] = "$ai_generation";
AIEvent["Embedding"] = "$ai_embedding";
return AIEvent;
}({});
function sanitizeValues(obj) {
if (obj === undefined || obj === null) {
return obj;
}
const jsonSafe = JSON.parse(JSON.stringify(obj));
if (typeof jsonSafe === 'string') {
// Sanitize lone surrogates by round-tripping through UTF-8
return new TextDecoder().decode(new TextEncoder().encode(jsonSafe));
} else if (Array.isArray(jsonSafe)) {
return jsonSafe.map(sanitizeValues);
} else if (jsonSafe && typeof jsonSafe === 'object') {
return Object.fromEntries(Object.entries(jsonSafe).map(([k, v]) => [k, sanitizeValues(v)]));
}
return jsonSafe;
}
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()
};
}
var version = "7.19.5";
const DEFAULT_MAX_DEPTH = 3;
const MAX_STACK_LINES = 20;
function serializeError(value, depth = DEFAULT_MAX_DEPTH) {
if (depth < 0 || value === null || typeof value !== 'object') {
return value;
}
if (value instanceof Error) {
const out = {
name: value.name,
message: value.message,
stack: truncateStack(value.stack)
};
for (const key of Object.keys(value)) {
out[key] = serializeError(value[key], depth - 1);
}
if (value.cause !== undefined) {
out.cause = serializeError(value.cause, depth - 1);
}
return out;
}
if (Array.isArray(value)) {
return value.map(item => serializeError(item, depth - 1));
}
return value;
}
function stringifyError(error) {
try {
return JSON.stringify(sanitizeValues(serializeError(error)));
} catch {
if (error instanceof Error) {
return JSON.stringify({
name: error.name,
message: error.message
});
}
return JSON.stringify({
message: String(error)
});
}
}
function truncateStack(stack) {
if (!stack) {
return stack;
}
const lines = stack.split('\n');
if (lines.length <= MAX_STACK_LINES) {
return stack;
}
return [...lines.slice(0, MAX_STACK_LINES), '... (truncated)'].join('\n');
}
/**
* Options for `captureAiGeneration`. Mirrors the `$ai_generation` event shape
* directly so that any caller — first-party SDK wrappers and external code
* alike — produces an identical event.
*/
/**
* Capture an `$ai_generation` (or `$ai_embedding`) event to PostHog.
*
* This is the canonical primitive that every `@posthog/ai` wrapper
* (`withTracing`, `OpenAI`, `Anthropic`, `GoogleGenAI`, …) funnels through, so
* external code can use it directly to instrument LLM calls made through
* arbitrary clients (Cloudflare Workers AI, custom HTTP, etc.) and get the
* same events the SDK wrappers produce.
*
* When `error` is set, the event is captured as an error. If the error is an
* object, it is mutated in place to set `__posthog_previously_captured_error`
* so callers can re-throw the original error reference safely.
*/
const captureAiGeneration = async (client, options) => {
if (!client.capture) {
return;
}
const traceId = options.traceId ?? v4();
const eventType = options.eventType ?? AIEvent.Generation;
const privacyMode = options.privacyMode ?? false;
const usage = options.usage ?? {};
const safeInput = sanitizeValues(options.input);
const safeOutput = sanitizeValues(options.output);
let httpStatus = options.httpStatus;
let errorData = {};
if (options.error) {
if (httpStatus === undefined) {
if (typeof options.error === 'object' && 'status' in options.error && typeof options.error.status === 'number') {
httpStatus = options.error.status;
} else {
httpStatus = 500;
}
}
let exceptionId;
if (client.options?.enableExceptionAutocapture) {
exceptionId = uuidv7();
client.captureException(options.error, undefined, {
$ai_trace_id: traceId
}, exceptionId);
if (typeof options.error === 'object') {
options.error.__posthog_previously_captured_error = true;
}
}
errorData = {
$ai_is_error: true,
$ai_error: stringifyError(options.error),
$exception_event_id: exceptionId
};
}
httpStatus = httpStatus ?? 200;
let costOverrideData = {};
if (options.costOverride) {
const inputCostUSD = (options.costOverride.inputCost ?? 0) * (usage.inputTokens ?? 0);
const outputCostUSD = (options.costOverride.outputCost ?? 0) * (usage.outputTokens ?? 0);
costOverrideData = {
$ai_input_cost_usd: inputCostUSD,
$ai_output_cost_usd: outputCostUSD,
$ai_total_cost_usd: inputCostUSD + outputCostUSD
};
}
const additionalTokenValues = {
...(usage.reasoningTokens ? {
$ai_reasoning_tokens: usage.reasoningTokens
} : {}),
...(usage.cacheReadInputTokens ? {
$ai_cache_read_input_tokens: usage.cacheReadInputTokens
} : {}),
...(usage.cacheCreationInputTokens ? {
$ai_cache_creation_input_tokens: usage.cacheCreationInputTokens
} : {}),
...(usage.webSearchCount ? {
$ai_web_search_count: usage.webSearchCount
} : {}),
...(usage.rawUsage ? {
$ai_usage: usage.rawUsage
} : {})
};
const properties = {
$ai_lib: 'posthog-ai',
$ai_lib_version: version,
$ai_provider: options.providerOverride ?? options.provider,
$ai_model: options.modelOverride ?? options.model,
$ai_model_parameters: options.modelParameters ?? {},
$ai_input: withPrivacyMode(client, privacyMode, safeInput),
$ai_output_choices: withPrivacyMode(client, privacyMode, safeOutput),
$ai_http_status: httpStatus,
$ai_input_tokens: usage.inputTokens ?? 0,
...(usage.outputTokens !== undefined ? {
$ai_output_tokens: usage.outputTokens
} : {}),
...additionalTokenValues,
$ai_latency: options.latency ?? 0,
...(options.timeToFirstToken !== undefined ? {
$ai_time_to_first_token: options.timeToFirstToken
} : {}),
$ai_trace_id: traceId,
$ai_base_url: options.baseURL ?? '',
...options.properties,
$ai_tokens_source: getTokensSource(options.properties),
...(options.distinctId ? {} : {
$process_person_profile: false
}),
...(options.stopReason ? {
$ai_stop_reason: options.stopReason
} : {}),
...(options.tools ? {
$ai_tools: options.tools
} : {}),
...errorData,
...costOverrideData
};
const event = {
distinctId: options.distinctId ?? traceId,
event: eventType,
properties,
groups: options.groups
};
if (options.captureImmediate) {
await client.captureImmediate(event);
} else {
client.capture(event);
}
};
class PostHogAnthropic extends AnthropicOriginal {
constructor(config) {
const {
posthog,
...anthropicConfig
} = config;
super(anthropicConfig);
this.phClient = posthog;
this.messages = new WrappedMessages(this, this.phClient);
}
}
class WrappedMessages extends AnthropicOriginal.Messages {
constructor(parentClient, phClient) {
super(parentClient);
this.phClient = phClient;
this.baseURL = parentClient.baseURL;
}
create(body, options) {
const {
providerParams: anthropicParams,
posthogParams
} = extractPosthogParams(body);
const startTime = Date.now();
const parentPromise = super.create(anthropicParams, options);
if (anthropicParams.stream) {
return parentPromise.then(value => {
let accumulatedContent = '';
const contentBlocks = [];
const toolsInProgress = new Map();
let currentTextBlock = null;
let firstTokenTime;
let stopReason;
const usage = {
inputTokens: 0,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
webSearchCount: 0
};
let lastRawUsage;
if ('tee' in value) {
const [stream1, stream2] = value.tee();
(async () => {
try {
for await (const chunk of stream1) {
// Handle content block start events
if (chunk.type === 'content_block_start') {
if (chunk.content_block?.type === 'text') {
currentTextBlock = {
type: 'text',
text: ''
};
contentBlocks.push(currentTextBlock);
} else if (chunk.content_block?.type === 'tool_use') {
if (firstTokenTime === undefined) {
firstTokenTime = Date.now();
}
const toolBlock = {
type: 'function',
id: chunk.content_block.id,
function: {
name: chunk.content_block.name,
arguments: {}
}
};
contentBlocks.push(toolBlock);
toolsInProgress.set(chunk.content_block.id, {
block: toolBlock,
inputString: ''
});
currentTextBlock = null;
}
}
// Handle text delta events
if ('delta' in chunk) {
if ('text' in chunk.delta) {
const delta = chunk.delta.text;
if (firstTokenTime === undefined) {
firstTokenTime = Date.now();
}
accumulatedContent += delta;
if (currentTextBlock) {
currentTextBlock.text += delta;
}
}
}
// Handle tool input delta events
if (chunk.type === 'content_block_delta' && chunk.delta?.type === 'input_json_delta') {
const block = chunk.index !== undefined ? contentBlocks[chunk.index] : undefined;
const toolId = block?.type === 'function' ? block.id : undefined;
if (toolId && toolsInProgress.has(toolId)) {
const tool = toolsInProgress.get(toolId);
if (tool) {
tool.inputString += chunk.delta.partial_json || '';
}
}
}
// Handle content block stop events
if (chunk.type === 'content_block_stop') {
currentTextBlock = null;
// Parse accumulated tool input
if (chunk.index !== undefined) {
const block = contentBlocks[chunk.index];
if (block?.type === 'function' && block.id && toolsInProgress.has(block.id)) {
const tool = toolsInProgress.get(block.id);
if (tool) {
try {
block.function.arguments = JSON.parse(tool.inputString);
} catch (e) {
// Keep empty object if parsing fails
console.error('Error parsing tool input:', e);
}
}
toolsInProgress.delete(block.id);
}
}
}
if (chunk.type == 'message_start') {
lastRawUsage = chunk.message.usage;
usage.inputTokens = chunk.message.usage.input_tokens ?? 0;
usage.cacheCreationInputTokens = chunk.message.usage.cache_creation_input_tokens ?? 0;
usage.cacheReadInputTokens = chunk.message.usage.cache_read_input_tokens ?? 0;
usage.webSearchCount = chunk.message.usage.server_tool_use?.web_search_requests ?? 0;
}
if ('usage' in chunk) {
lastRawUsage = chunk.usage;
usage.outputTokens = chunk.usage.output_tokens ?? 0;
// Update web search count if present in delta
if (chunk.usage.server_tool_use?.web_search_requests !== undefined) {
usage.webSearchCount = chunk.usage.server_tool_use.web_search_requests;
}
}
if (chunk.type === 'message_delta' && 'delta' in chunk) {
const delta = chunk.delta;
if ('stop_reason' in delta && typeof delta.stop_reason === 'string' && delta.stop_reason) {
stopReason = delta.stop_reason;
}
}
}
usage.rawUsage = lastRawUsage;
const latency = (Date.now() - startTime) / 1000;
const timeToFirstToken = firstTokenTime !== undefined ? (firstTokenTime - startTime) / 1000 : undefined;
const availableTools = extractAvailableToolCalls('anthropic', anthropicParams);
// Format output to match non-streaming version
const formattedOutput = contentBlocks.length > 0 ? [{
role: 'assistant',
content: contentBlocks
}] : [{
role: 'assistant',
content: [{
type: 'text',
text: accumulatedContent
}]
}];
await captureAiGeneration(this.phClient, {
...posthogParams,
model: anthropicParams.model,
provider: 'anthropic',
input: sanitizeAnthropic(mergeSystemPrompt(anthropicParams, 'anthropic')),
output: formattedOutput,
latency,
timeToFirstToken,
baseURL: this.baseURL,
modelParameters: getModelParams(body),
httpStatus: 200,
usage,
stopReason,
tools: availableTools
});
} catch (error) {
await captureAiGeneration(this.phClient, {
...posthogParams,
model: anthropicParams.model,
provider: 'anthropic',
input: sanitizeAnthropic(mergeSystemPrompt(anthropicParams)),
output: [],
latency: 0,
baseURL: this.baseURL,
modelParameters: getModelParams(body),
usage: {
inputTokens: 0,
outputTokens: 0
},
error: error
});
throw error;
}
})();
// Return the other stream to the user
return stream2;
}
return value;
});
} else {
const wrappedPromise = parentPromise.then(async result => {
if ('content' in result) {
const latency = (Date.now() - startTime) / 1000;
const availableTools = extractAvailableToolCalls('anthropic', anthropicParams);
await captureAiGeneration(this.phClient, {
...posthogParams,
model: anthropicParams.model,
provider: 'anthropic',
input: sanitizeAnthropic(mergeSystemPrompt(anthropicParams)),
output: formatResponseAnthropic(result),
latency,
baseURL: this.baseURL,
modelParameters: getModelParams(body),
httpStatus: 200,
usage: {
inputTokens: result.usage.input_tokens ?? 0,
outputTokens: result.usage.output_tokens ?? 0,
cacheCreationInputTokens: result.usage.cache_creation_input_tokens ?? 0,
cacheReadInputTokens: result.usage.cache_read_input_tokens ?? 0,
webSearchCount: result.usage.server_tool_use?.web_search_requests ?? 0,
rawUsage: result.usage
},
stopReason: result.stop_reason ?? undefined,
tools: availableTools
});
}
return result;
}, async error => {
await captureAiGeneration(this.phClient, {
...posthogParams,
model: anthropicParams.model,
provider: 'anthropic',
input: sanitizeAnthropic(mergeSystemPrompt(anthropicParams)),
output: [],
latency: 0,
baseURL: this.baseURL,
modelParameters: getModelParams(body),
httpStatus: error?.status ? error.status : 500,
usage: {
inputTokens: 0,
outputTokens: 0
},
error: error
});
throw error;
});
return wrappedPromise;
}
}
}
export { PostHogAnthropic as Anthropic, PostHogAnthropic, WrappedMessages, PostHogAnthropic as default };
//# sourceMappingURL=index.mjs.map