@posthog/ai
Version:
PostHog Node.js AI integrations
1,470 lines (1,464 loc) • 184 kB
JavaScript
import { OpenAI, AzureOpenAI } from 'openai';
import * as uuid from 'uuid';
import { v4 } from 'uuid';
import { uuidv7 } from '@posthog/core';
import AnthropicOriginal from '@anthropic-ai/sdk';
import { GoogleGenAI } from '@google/genai';
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 processMessages = (messages, transformContent) => {
if (!messages) return messages;
const processContent = content => {
if (typeof content === 'string') return content;
if (!content) return content;
if (Array.isArray(content)) {
return content.map(transformContent);
}
// Handle single object content
return transformContent(content);
};
const processMessage = msg => {
if (!isObject(msg) || !('content' in msg)) return msg;
return {
...msg,
content: processContent(msg.content)
};
};
// Handle both arrays and single messages
if (Array.isArray(messages)) {
return messages.map(processMessage);
}
return processMessage(messages);
};
// ============================================
// Provider-Specific Image Sanitizers
// ============================================
const sanitizeOpenAIImage = item => {
if (!isObject(item)) return item;
// Handle image_url format
if (item.type === 'image_url' && 'image_url' in item && isObject(item.image_url) && 'url' in item.image_url) {
return {
...item,
image_url: {
...item.image_url,
url: redactBase64DataUrl(item.image_url.url)
}
};
}
// Handle audio format
if (item.type === 'audio' && 'data' in item) {
if (isMultimodalEnabled()) return item;
return {
...item,
data: REDACTED_IMAGE_PLACEHOLDER
};
}
return item;
};
const sanitizeOpenAIResponseImage = item => {
if (!isObject(item)) return item;
// Handle input_image format
if (item.type === 'input_image' && 'image_url' in item) {
return {
...item,
image_url: redactBase64DataUrl(item.image_url)
};
}
return item;
};
const sanitizeAnthropicImage = item => {
if (isMultimodalEnabled()) return item;
if (!isObject(item)) return item;
// Handle Anthropic's image and document formats (same structure, different type field)
if ((item.type === 'image' || item.type === 'document') && 'source' in item && isObject(item.source) && item.source.type === 'base64' && 'data' in item.source) {
return {
...item,
source: {
...item.source,
data: REDACTED_IMAGE_PLACEHOLDER
}
};
}
return item;
};
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 sanitizeLangChainImage = item => {
if (!isObject(item)) return item;
// OpenAI style
if (item.type === 'image_url' && 'image_url' in item && isObject(item.image_url) && 'url' in item.image_url) {
return {
...item,
image_url: {
...item.image_url,
url: redactBase64DataUrl(item.image_url.url)
}
};
}
// Direct image with data field
if (item.type === 'image' && 'data' in item) {
return {
...item,
data: redactBase64DataUrl(item.data)
};
}
// Anthropic style
if (item.type === 'image' && 'source' in item && isObject(item.source) && 'data' in item.source) {
if (isMultimodalEnabled()) return item;
return {
...item,
source: {
...item.source,
data: redactBase64DataUrl(item.source.data)
}
};
}
// Google style
if (item.type === 'media' && 'data' in item) {
return {
...item,
data: redactBase64DataUrl(item.data)
};
}
return item;
};
// Export individual sanitizers for tree-shaking
const sanitizeOpenAI = data => {
return processMessages(data, sanitizeOpenAIImage);
};
const sanitizeOpenAIResponse = data => {
return processMessages(data, sanitizeOpenAIResponseImage);
};
const sanitizeAnthropic = data => {
return processMessages(data, sanitizeAnthropicImage);
};
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);
};
const sanitizeLangChain = data => {
return processMessages(data, sanitizeLangChainImage);
};
// limit large outputs by truncating to 200kb (approx 200k bytes)
const MAX_OUTPUT_SIZE = 200000;
const STRING_FORMAT = 'utf8';
/**
* 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 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 formatResponseOpenAI = response => {
const output = [];
if (response.choices) {
for (const choice of response.choices) {
const content = [];
let role = 'assistant';
if (choice.message) {
if (choice.message.role) {
role = choice.message.role;
}
if (choice.message.content) {
content.push({
type: 'text',
text: choice.message.content
});
}
if (choice.message.tool_calls) {
for (const toolCall of choice.message.tool_calls) {
content.push({
type: 'function',
id: toolCall.id,
function: {
name: toolCall.function.name,
arguments: toolCall.function.arguments
}
});
}
}
// Handle audio output (gpt-4o-audio-preview)
if (choice.message.audio) {
content.push({
type: 'audio',
...choice.message.audio
});
}
}
if (content.length > 0) {
output.push({
role,
content
});
}
}
}
// Handle Responses API format
if (response.output) {
const content = [];
let role = 'assistant';
for (const item of response.output) {
if (item.type === 'message') {
role = item.role;
if (item.content && Array.isArray(item.content)) {
for (const contentItem of item.content) {
if (contentItem.type === 'output_text' && contentItem.text) {
content.push({
type: 'text',
text: contentItem.text
});
} else if (contentItem.text) {
content.push({
type: 'text',
text: contentItem.text
});
} else if (contentItem.type === 'input_image' && contentItem.image_url) {
content.push({
type: 'image',
image: contentItem.image_url
});
}
}
} else if (item.content) {
content.push({
type: 'text',
text: String(item.content)
});
}
} else if (item.type === 'function_call') {
content.push({
type: 'function',
id: item.call_id || item.id || '',
function: {
name: item.name,
arguments: item.arguments || {}
}
});
}
}
if (content.length > 0) {
output.push({
role,
content
});
}
}
return output;
};
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 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;
};
function toSafeString(input) {
if (input === undefined || input === null) {
return '';
}
if (typeof input === 'string') {
return input;
}
try {
return JSON.stringify(input);
} catch {
console.warn('Failed to stringify input', input);
return '';
}
}
const truncate = input => {
const str = toSafeString(input);
if (str === '') {
return '';
}
// Check if we need to truncate and ensure STRING_FORMAT is respected
const encoder = new TextEncoder();
const buffer = encoder.encode(str);
if (buffer.length <= MAX_OUTPUT_SIZE) {
// Ensure STRING_FORMAT is respected
return new TextDecoder(STRING_FORMAT).decode(buffer);
}
// Truncate the buffer and ensure a valid string is returned
const truncatedBuffer = buffer.slice(0, MAX_OUTPUT_SIZE);
// fatal: false means we get U+FFFD at the end if truncation broke the encoding
const decoder = new TextDecoder(STRING_FORMAT, {
fatal: false
});
let truncatedStr = decoder.decode(truncatedBuffer);
if (truncatedStr.endsWith('\uFFFD')) {
truncatedStr = truncatedStr.slice(0, -1);
}
return `${truncatedStr}... [truncated]`;
};
/**
* Calculate web search count from raw API response.
*
* Uses a two-tier detection strategy:
* Priority 1 (Exact Count): Count actual web search calls when available
* Priority 2 (Binary Detection): Return 1 if web search indicators are present, 0 otherwise
*
* @param result - Raw API response from any provider (OpenAI, Perplexity, OpenRouter, Gemini, etc.)
* @returns Number of web searches performed (exact count or binary 1/0)
*/
function calculateWebSearchCount(result) {
if (!result || typeof result !== 'object') {
return 0;
}
// Priority 1: Exact Count
// Check for OpenAI Responses API web_search_call items
if ('output' in result && Array.isArray(result.output)) {
let count = 0;
for (const item of result.output) {
if (typeof item === 'object' && item !== null && 'type' in item && item.type === 'web_search_call') {
count++;
}
}
if (count > 0) {
return count;
}
}
// Priority 2: Binary Detection (1 or 0)
// Check for citations at root level (Perplexity)
if ('citations' in result && Array.isArray(result.citations) && result.citations.length > 0) {
return 1;
}
// Check for search_results at root level (Perplexity via OpenRouter)
if ('search_results' in result && Array.isArray(result.search_results) && result.search_results.length > 0) {
return 1;
}
// Check for usage.search_context_size (Perplexity via OpenRouter)
if ('usage' in result && typeof result.usage === 'object' && result.usage !== null) {
if ('search_context_size' in result.usage && result.usage.search_context_size) {
return 1;
}
}
// Check for annotations with url_citation in choices[].message or choices[].delta (OpenAI/Perplexity)
if ('choices' in result && Array.isArray(result.choices)) {
for (const choice of result.choices) {
if (typeof choice === 'object' && choice !== null) {
// Check both message (non-streaming) and delta (streaming) for annotations
const content = ('message' in choice ? choice.message : null) || ('delta' in choice ? choice.delta : null);
if (typeof content === 'object' && content !== null && 'annotations' in content) {
const annotations = content.annotations;
if (Array.isArray(annotations)) {
const hasUrlCitation = annotations.some(ann => {
return typeof ann === 'object' && ann !== null && 'type' in ann && ann.type === 'url_citation';
});
if (hasUrlCitation) {
return 1;
}
}
}
}
}
}
// Check for annotations in output[].content[] (OpenAI Responses API)
if ('output' in result && Array.isArray(result.output)) {
for (const item of result.output) {
if (typeof item === 'object' && item !== null && 'content' in item) {
const content = item.content;
if (Array.isArray(content)) {
for (const contentItem of content) {
if (typeof contentItem === 'object' && contentItem !== null && 'annotations' in contentItem) {
const annotations = contentItem.annotations;
if (Array.isArray(annotations)) {
const hasUrlCitation = annotations.some(ann => {
return typeof ann === 'object' && ann !== null && 'type' in ann && ann.type === 'url_citation';
});
if (hasUrlCitation) {
return 1;
}
}
}
}
}
}
}
}
// Check for grounding_metadata (Gemini)
if ('candidates' in result && Array.isArray(result.candidates)) {
for (const candidate of result.candidates) {
if (typeof candidate === 'object' && candidate !== null && 'grounding_metadata' in candidate && candidate.grounding_metadata) {
return 1;
}
}
}
return 0;
}
/**
* 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 (provider === 'anthropic') {
if (params.tools) {
return params.tools;
}
return null;
} else if (provider === 'gemini') {
if (params.config && params.config.tools) {
return params.config.tools;
}
return null;
} else if (provider === 'openai') {
if (params.tools) {
return params.tools;
}
return null;
} else if (provider === 'vercel') {
if (params.tools) {
return params.tools;
}
return null;
}
return null;
};
var AIEvent;
(function (AIEvent) {
AIEvent["Generation"] = "$ai_generation";
AIEvent["Embedding"] = "$ai_embedding";
})(AIEvent || (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();
};
function formatOpenAIResponsesInput(input, instructions) {
const messages = [];
if (instructions) {
messages.push({
role: 'system',
content: instructions
});
}
if (Array.isArray(input)) {
for (const item of input) {
if (typeof item === 'string') {
messages.push({
role: 'user',
content: item
});
} else if (item && typeof item === 'object') {
const obj = item;
const role = isString(obj.role) ? obj.role : 'user';
// Handle content properly - preserve structure for objects/arrays
const content = obj.content ?? obj.text ?? item;
messages.push({
role,
content: toContentString(content)
});
} else {
messages.push({
role: 'user',
content: toContentString(item)
});
}
}
} else if (typeof input === 'string') {
messages.push({
role: 'user',
content: input
});
} else if (input) {
messages.push({
role: 'user',
content: toContentString(input)
});
}
return messages;
}
/**
* Checks if a ResponseStreamEvent chunk represents the first token/content from the model.
* This includes various content types like text, reasoning, audio, and refusals.
*/
function isResponseTokenChunk(chunk) {
return chunk.type === 'response.output_item.added' || chunk.type === 'response.content_part.added' || chunk.type === 'response.output_text.delta' || chunk.type === 'response.reasoning_text.delta' || chunk.type === 'response.reasoning_summary_text.delta' || chunk.type === 'response.audio.delta' || chunk.type === 'response.audio.transcript.delta' || chunk.type === 'response.refusal.delta';
}
const Chat = OpenAI.Chat;
const Completions = Chat.Completions;
const Responses = OpenAI.Responses;
const Embeddings = OpenAI.Embeddings;
const Audio = OpenAI.Audio;
const Transcriptions = OpenAI.Audio.Transcriptions;
class PostHogOpenAI extends OpenAI {
constructor(config) {
const {
posthog,
...openAIConfig
} = config;
super(openAIConfig);
this.phClient = posthog;
this.chat = new WrappedChat$1(this, this.phClient);
this.responses = new WrappedResponses$1(this, this.phClient);
this.embeddings = new WrappedEmbeddings$1(this, this.phClient);
this.audio = new WrappedAudio(this, this.phClient);
}
}
let WrappedChat$1 = class WrappedChat extends Chat {
constructor(parentClient, phClient) {
super(parentClient);
this.completions = new WrappedCompletions$1(parentClient, phClient);
}
};
let WrappedCompletions$1 = class WrappedCompletions extends Completions {
constructor(client, phClient) {
super(client);
this.phClient = phClient;
this.baseURL = client.baseURL;
}
// --- Implementation Signature
create(body, options) {
const {
providerParams: openAIParams,
posthogParams
} = extractPosthogParams(body);
const startTime = Date.now();
const parentPromise = super.create(openAIParams, options);
if (openAIParams.stream) {
return parentPromise.then(value => {
if ('tee' in value) {
const [stream1, stream2] = value.tee();
(async () => {
try {
const contentBlocks = [];
let accumulatedContent = '';
let modelFromResponse;
let firstTokenTime;
let usage = {
inputTokens: 0,
outputTokens: 0,
webSearchCount: 0
};
// Map to track in-progress tool calls
const toolCallsInProgress = new Map();
let rawUsageData;
for await (const chunk of stream1) {
// Extract model from chunk (Chat Completions chunks have model field)
if (!modelFromResponse && chunk.model) {
modelFromResponse = chunk.model;
}
const choice = chunk?.choices?.[0];
const chunkWebSearchCount = calculateWebSearchCount(chunk);
if (chunkWebSearchCount > 0 && chunkWebSearchCount > (usage.webSearchCount ?? 0)) {
usage.webSearchCount = chunkWebSearchCount;
}
// Handle text content
const deltaContent = choice?.delta?.content;
if (deltaContent) {
if (firstTokenTime === undefined) {
firstTokenTime = Date.now();
}
accumulatedContent += deltaContent;
}
// Handle tool calls
const deltaToolCalls = choice?.delta?.tool_calls;
if (deltaToolCalls && Array.isArray(deltaToolCalls)) {
if (firstTokenTime === undefined) {
firstTokenTime = Date.now();
}
for (const toolCall of deltaToolCalls) {
const index = toolCall.index;
if (index !== undefined) {
if (!toolCallsInProgress.has(index)) {
// New tool call
toolCallsInProgress.set(index, {
id: toolCall.id || '',
name: toolCall.function?.name || '',
arguments: ''
});
}
const inProgressCall = toolCallsInProgress.get(index);
if (inProgressCall) {
// Update tool call data
if (toolCall.id) {
inProgressCall.id = toolCall.id;
}
if (toolCall.function?.name) {
inProgressCall.name = toolCall.function.name;
}
if (toolCall.function?.arguments) {
inProgressCall.arguments += toolCall.function.arguments;
}
}
}
}
}
// Handle usage information
if (chunk.usage) {
rawUsageData = chunk.usage;
usage = {
...usage,
inputTokens: chunk.usage.prompt_tokens ?? 0,
outputTokens: chunk.usage.completion_tokens ?? 0,
reasoningTokens: chunk.usage.completion_tokens_details?.reasoning_tokens ?? 0,
cacheReadInputTokens: chunk.usage.prompt_tokens_details?.cached_tokens ?? 0
};
}
}
// Build final content blocks
if (accumulatedContent) {
contentBlocks.push({
type: 'text',
text: accumulatedContent
});
}
// Add completed tool calls to content blocks
for (const toolCall of toolCallsInProgress.values()) {
if (toolCall.name) {
contentBlocks.push({
type: 'function',
id: toolCall.id,
function: {
name: toolCall.name,
arguments: toolCall.arguments
}
});
}
}
// Format output to match non-streaming version
const formattedOutput = contentBlocks.length > 0 ? [{
role: 'assistant',
content: contentBlocks
}] : [{
role: 'assistant',
content: [{
type: 'text',
text: ''
}]
}];
const latency = (Date.now() - startTime) / 1000;
const timeToFirstToken = firstTokenTime !== undefined ? (firstTokenTime - startTime) / 1000 : undefined;
const availableTools = extractAvailableToolCalls('openai', openAIParams);
await sendEventToPosthog({
client: this.phClient,
...posthogParams,
model: openAIParams.model ?? modelFromResponse,
provider: 'openai',
input: sanitizeOpenAI(openAIParams.messages),
output: formattedOutput,
latency,
timeToFirstToken,
baseURL: this.baseURL,
params: body,
httpStatus: 200,
usage: {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
reasoningTokens: usage.reasoningTokens,
cacheReadInputTokens: usage.cacheReadInputTokens,
webSearchCount: usage.webSearchCount,
rawUsage: rawUsageData
},
tools: availableTools
});
} catch (error) {
const enrichedError = await sendEventWithErrorToPosthog({
client: this.phClient,
...posthogParams,
model: openAIParams.model,
provider: 'openai',
input: sanitizeOpenAI(openAIParams.messages),
output: [],
latency: 0,
baseURL: this.baseURL,
params: body,
usage: {
inputTokens: 0,
outputTokens: 0
},
error
});
throw enrichedError;
}
})();
// Return the other stream to the user
return stream2;
}
return value;
});
} else {
const wrappedPromise = parentPromise.then(async result => {
if ('choices' in result) {
const latency = (Date.now() - startTime) / 1000;
const availableTools = extractAvailableToolCalls('openai', openAIParams);
const formattedOutput = formatResponseOpenAI(result);
await sendEventToPosthog({
client: this.phClient,
...posthogParams,
model: openAIParams.model ?? result.model,
provider: 'openai',
input: sanitizeOpenAI(openAIParams.messages),
output: formattedOutput,
latency,
baseURL: this.baseURL,
params: body,
httpStatus: 200,
usage: {
inputTokens: result.usage?.prompt_tokens ?? 0,
outputTokens: result.usage?.completion_tokens ?? 0,
reasoningTokens: result.usage?.completion_tokens_details?.reasoning_tokens ?? 0,
cacheReadInputTokens: result.usage?.prompt_tokens_details?.cached_tokens ?? 0,
webSearchCount: calculateWebSearchCount(result),
rawUsage: result.usage
},
tools: availableTools
});
}
return result;
}, async error => {
const httpStatus = error && typeof error === 'object' && 'status' in error ? error.status ?? 500 : 500;
await sendEventToPosthog({
client: this.phClient,
...posthogParams,
model: openAIParams.model,
provider: 'openai',
input: sanitizeOpenAI(openAIParams.messages),
output: [],
latency: 0,
baseURL: this.baseURL,
params: body,
httpStatus,
usage: {
inputTokens: 0,
outputTokens: 0
},
error: JSON.stringify(error)
});
throw error;
});
return wrappedPromise;
}
}
};
let WrappedResponses$1 = class WrappedResponses extends Responses {
constructor(client, phClient) {
super(client);
this.phClient = phClient;
this.baseURL = client.baseURL;
}
// --- Implementation Signature
create(body, options) {
const {
providerParams: openAIParams,
posthogParams
} = extractPosthogParams(body);
const startTime = Date.now();
const parentPromise = super.create(openAIParams, options);
if (openAIParams.stream) {
return parentPromise.then(value => {
if ('tee' in value && typeof value.tee === 'function') {
const [stream1, stream2] = value.tee();
(async () => {
try {
let finalContent = [];
let modelFromResponse;
let firstTokenTime;
let usage = {
inputTokens: 0,
outputTokens: 0,
webSearchCount: 0
};
let rawUsageData;
for await (const chunk of stream1) {
// Track first token time on content delta events
if (firstTokenTime === undefined && isResponseTokenChunk(chunk)) {
firstTokenTime = Date.now();
}
if ('response' in chunk && chunk.response) {
// Extract model from response object in chunk (for stored prompts)
if (!modelFromResponse && chunk.response.model) {
modelFromResponse = chunk.response.model;
}
const chunkWebSearchCount = calculateWebSearchCount(chunk.response);
if (chunkWebSearchCount > 0 && chunkWebSearchCount > (usage.webSearchCount ?? 0)) {
usage.webSearchCount = chunkWebSearchCount;
}
}
if (chunk.type === 'response.completed' && 'response' in chunk && chunk.response?.output && chunk.response.output.length > 0) {
finalContent = chunk.response.output;
}
if ('response' in chunk && chunk.response?.usage) {
rawUsageData = chunk.response.usage;
usage = {
...usage,
inputTokens: chunk.response.usage.input_tokens ?? 0,
outputTokens: chunk.response.usage.output_tokens ?? 0,
reasoningTokens: chunk.response.usage.output_tokens_details?.reasoning_tokens ?? 0,
cacheReadInputTokens: chunk.response.usage.input_tokens_details?.cached_tokens ?? 0
};
}
}
const latency = (Date.now() - startTime) / 1000;
const timeToFirstToken = firstTokenTime !== undefined ? (firstTokenTime - startTime) / 1000 : undefined;
const availableTools = extractAvailableToolCalls('openai', openAIParams);
await sendEventToPosthog({
client: this.phClient,
...posthogParams,
model: openAIParams.model ?? modelFromResponse,
provider: 'openai',
input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions),
output: finalContent,
latency,
timeToFirstToken,
baseURL: this.baseURL,
params: body,
httpStatus: 200,
usage: {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
reasoningTokens: usage.reasoningTokens,
cacheReadInputTokens: usage.cacheReadInputTokens,
webSearchCount: usage.webSearchCount,
rawUsage: rawUsageData
},
tools: availableTools
});
} catch (error) {
const enrichedError = await sendEventWithErrorToPosthog({
client: this.phClient,
...posthogParams,
model: openAIParams.model,
provider: 'openai',
input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions),
output: [],
latency: 0,
baseURL: this.baseURL,
params: body,
usage: {
inputTokens: 0,
outputTokens: 0
},
error: error
});
throw enrichedError;
}
})();
return stream2;
}
return value;
});
} else {
const wrappedPromise = parentPromise.then(async result => {
if ('output' in result) {
const latency = (Date.now() - startTime) / 1000;
const availableTools = extractAvailableToolCalls('openai', openAIParams);
const formattedOutput = formatResponseOpenAI({
output: result.output
});
await sendEventToPosthog({
client: this.phClient,
...posthogParams,
model: openAIParams.model ?? result.model,
provider: 'openai',
input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions),
output: formattedOutput,
latency,
baseURL: this.baseURL,
params: body,
httpStatus: 200,
usage: {
inputTokens: result.usage?.input_tokens ?? 0,
outputTokens: result.usage?.output_tokens ?? 0,
reasoningTokens: result.usage?.output_tokens_details?.reasoning_tokens ?? 0,
cacheReadInputTokens: result.usage?.input_tokens_details?.cached_tokens ?? 0,
webSearchCount: calculateWebSearchCount(result),
rawUsage: result.usage
},
tools: availableTools
});
}
return result;
}, async error => {
const httpStatus = error && typeof error === 'object' && 'status' in error ? error.status ?? 500 : 500;
await sendEventToPosthog({
client: this.phClient,
...posthogParams,
model: openAIParams.model,
provider: 'openai',
input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions),
output: [],
latency: 0,
baseURL: this.baseURL,
params: body,
httpStatus,
usage: {
inputTokens: 0,
outputTokens: 0
},
error: JSON.stringify(error)
});
throw error;
});
return wrappedPromise;
}
}
parse(body, options) {
const {
providerParams: openAIParams,
posthogParams
} = extractPosthogParams(body);
const startTime = Date.now();
const originalCreate = super.create.bind(this);
const originalSelfRecord = this;
const tempCreate = originalSelfRecord['create'];
originalSelfRecord['create'] = originalCreate;
try {
const parentPromise = super.parse(openAIParams, options);
const wrappedPromise = parentPromise.then(async result => {
const latency = (Date.now() - startTime) / 1000;
await sendEventToPosthog({
client: this.phClient,
...posthogParams,
model: openAIParams.model ?? result.model,
provider: 'openai',
input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions),
output: result.output,
latency,
baseURL: this.baseURL,
params: body,
httpStatus: 200,
usage: {
inputTokens: result.usage?.input_tokens ?? 0,
outputTokens: result.usage?.output_tokens ?? 0,
reasoningTokens: result.usage?.output_tokens_details?.reasoning_tokens ?? 0,
cacheReadInputTokens: result.usage?.input_tokens_details?.cached_tokens ?? 0,
rawUsage: result.usage
}
});
return result;
}, async error => {
const enrichedError = await sendEventWithErrorToPosthog({
client: this.phClient,
...posthogParams,
model: openAIParams.model,
provider: 'openai',
input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions),
output: [],
latency: 0,
baseURL: this.baseURL,
params: body,
usage: {
inputTokens: 0,
outputTokens: 0
},
error: JSON.stringify(error)
});
throw enrichedError;
});
return wrappedPromise;
} finally {
// Restore our wrapped create method
originalSelfRecord['create'] = tempCreate;
}
}
};
let WrappedEmbeddings$1 = class WrappedEmbeddings extends Embeddings {
constructor(client, phClient) {
super(client);
this.phClient = phClient;
this.baseURL = client.baseURL;
}
create(body, options) {
const {
providerParams: openAIParams,
posthogParams
} = extractPosthogParams(body);
const startTime = Date.now();
const parentPromise = super.create(openAIParams, options);
const wrappedPromise = parentPromise.then(async result => {
const latency = (Date.now() - startTime) / 1000;
await sendEventToPosthog({
client: this.phClient,
...posthogParams,
eventType: AIEvent.Embedding,
model: openAIParams.model,
provider: 'openai',
input: withPrivacyMode(this.phClient, posthogParams.privacyMode, openAIParams.input),
output: null,
// Embeddings don't have output content
latency,
baseURL: this.baseURL,
params: body,
httpStatus: 200,
usage: {
inputTokens: result.usage?.prompt_tokens ?? 0,
rawUsage: result.usage
}
});
return result;
}, async error => {
const httpStatus = error && typeof error === 'object' && 'status' in error ? error.status ?? 500 : 500;
await sendEventToPosthog({
client: this.phClient,
eventType: AIEvent.Embedding,
...posthogParams,
model: openAIParams.model,
provider: 'openai',
input: withPrivacyMode(this.phClient, posthogParams.privacyMode, openAIParams.input),
output: null,
// Embeddings don't have output content
latency: 0,
baseURL: this.baseURL,
params: body,
httpStatus,
usage: {
inputTokens: 0
},
error: JSON.stringify(error)
});
throw error;
});
return wrappedPromise;
}
};
class WrappedAudio extends Audio {
constructor(parentClient, phClient) {
super(parentClient);
this.transcriptions = new WrappedTranscriptions(parentClient, phClient);
}
}
class WrappedTranscriptions extends Transcriptions {
constructor(client, phClient) {
super(client);
this.phClient = phClient;
this.baseURL = client.baseURL;
}
// --- Implementation Signature
create(body, options) {
const {
providerParams: openAIParams,
posthogParams
} = extractPosthogParams(body);
const startTime = Date.now();
const parentPromise = openAIParams.stream ? super.create(openAIParams, options) : super.create(openAIParams, options);
if (openAIParams.stream) {
return parentPromise.then(value => {
if ('tee' in value && typeof value.tee === 'function') {
const [stream1, stream2] = value.tee();
(async () => {
try {
let finalContent = '';
let firstTokenTime;
let usage = {
inputTokens: 0,
outputTokens: 0
};
const doneEvent = 'transcript.text.done';
for await (const chunk of stream1) {
// Track first token on text delta events
if (firstTokenTime === undefined && chunk.type === 'transcript.text.delta') {
firstTokenTime = Date.now();
}
if (chunk.type === doneEvent && 'text' in chunk && chunk.text && chunk.text.length > 0) {
finalContent = chunk.text;
}
if ('usage' in chunk && chunk.usage) {
usage = {
inputTokens: chunk.usage?.type === 'tokens' ? chunk.usage.input_tokens ?? 0 : 0,
outputTokens: chunk.usage?.type === 'tokens' ? chunk.usage.output_tokens ?? 0 : 0,
rawUsage: chunk.usage
};
}
}
const latency = (Date.now() - startTime) / 1000;
const timeToFirstToken = firstTokenTime !== undefined ? (firstTokenTime - startTime) / 1000 : undefined;
const availableTools = extractAvailableToolCall