@posthog/ai
Version:
PostHog Node.js AI integrations
1,422 lines (1,367 loc) • 50.7 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var openai = require('openai');
var uuid = require('uuid');
var core = require('@posthog/core');
// Type guards for safer type checking
const isString = value => {
return typeof value === 'string';
};
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 sanitizeOpenAI = data => redactor.redact(data);
const sanitizeOpenAIResponse = 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
});
/**
* 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 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 withPrivacyMode = (client, privacyMode, input) => {
return client.privacy_mode || privacyMode ? null : input;
};
/**
* 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 (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 ?? uuid.v4()
};
}
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;
}
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 ?? uuid.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 = core.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);
}
};
/**
* 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.OpenAI.Chat;
const Completions = Chat.Completions;
const Responses = openai.OpenAI.Responses;
const Embeddings = openai.OpenAI.Embeddings;
const Audio = openai.OpenAI.Audio;
const Transcriptions = openai.OpenAI.Audio.Transcriptions;
class PostHogOpenAI extends openai.OpenAI {
constructor(config) {
const {
posthog,
...openAIConfig
} = config;
super(openAIConfig);
this.phClient = posthog;
this.chat = new WrappedChat(this, this.phClient);
this.responses = new WrappedResponses(this, this.phClient);
this.embeddings = new WrappedEmbeddings(this, this.phClient);
this.audio = new WrappedAudio(this, this.phClient);
}
}
class WrappedChat extends Chat {
constructor(parentClient, phClient) {
super(parentClient);
this.completions = new WrappedCompletions(parentClient, phClient);
}
}
class WrappedCompletions extends Completions {
constructor(client, phClient) {
super(client);
this.phClient = phClient;
this.baseURL = client.baseURL;
}
// --- Overload #1: Non-streaming
// --- Overload #2: Streaming
// --- Overload #3: Generic base
// --- 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 stopReason;
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];
if (choice?.finish_reason) {
stopReason = choice.finish_reason;
}
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 captureAiGeneration(this.phClient, {
...posthogParams,
model: openAIParams.model ?? modelFromResponse,
provider: 'openai',
input: sanitizeOpenAI(openAIParams.messages),
output: formattedOutput,
latency,
timeToFirstToken,
baseURL: this.baseURL,
modelParameters: getModelParams(body),
httpStatus: 200,
usage: {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
reasoningTokens: usage.reasoningTokens,
cacheReadInputTokens: usage.cacheReadInputTokens,
webSearchCount: usage.webSearchCount,
rawUsage: rawUsageData
},
stopReason,
tools: availableTools
});
} catch (error) {
await captureAiGeneration(this.phClient, {
...posthogParams,
model: openAIParams.model,
provider: 'openai',
input: sanitizeOpenAI(openAIParams.messages),
output: [],
latency: 0,
baseURL: this.baseURL,
modelParameters: getModelParams(body),
usage: {
inputTokens: 0,
outputTokens: 0
},
error
});
throw error;
}
})();
// 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 captureAiGeneration(this.phClient, {
...posthogParams,
model: openAIParams.model ?? result.model,
provider: 'openai',
input: sanitizeOpenAI(openAIParams.messages),
output: formattedOutput,
latency,
baseURL: this.baseURL,
modelParameters: getModelParams(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
},
stopReason: result.choices[0]?.finish_reason ?? undefined,
tools: availableTools
});
}
return result;
}, async error => {
const httpStatus = error && typeof error === 'object' && 'status' in error ? error.status ?? 500 : 500;
await captureAiGeneration(this.phClient, {
...posthogParams,
model: openAIParams.model,
provider: 'openai',
input: sanitizeOpenAI(openAIParams.messages),
output: [],
latency: 0,
baseURL: this.baseURL,
modelParameters: getModelParams(body),
httpStatus,
usage: {
inputTokens: 0,
outputTokens: 0
},
error
});
throw error;
});
return wrappedPromise;
}
}
}
class WrappedResponses extends Responses {
constructor(client, phClient) {
super(client);
this.phClient = phClient;
this.baseURL = client.baseURL;
}
// --- Overload #1: Non-streaming
// --- Overload #2: Streaming
// --- Overload #3: Generic base
// --- 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 stopReason;
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 (chunk.response.status) {
stopReason = chunk.response.status;
}
}
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 captureAiGeneration(this.phClient, {
...posthogParams,
model: openAIParams.model ?? modelFromResponse,
provider: 'openai',
input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions),
output: finalContent,
latency,
timeToFirstToken,
baseURL: this.baseURL,
modelParameters: getModelParams(body),
httpStatus: 200,
usage: {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
reasoningTokens: usage.reasoningTokens,
cacheReadInputTokens: usage.cacheReadInputTokens,
webSearchCount: usage.webSearchCount,
rawUsage: rawUsageData
},
stopReason,
tools: availableTools
});
} catch (error) {
await captureAiGeneration(this.phClient, {
...posthogParams,
model: openAIParams.model,
provider: 'openai',
input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions),
output: [],
latency: 0,
baseURL: this.baseURL,
modelParameters: getModelParams(body),
usage: {
inputTokens: 0,
outputTokens: 0
},
error
});
throw error;
}
})();
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 captureAiGeneration(this.phClient, {
...posthogParams,
model: openAIParams.model ?? result.model,
provider: 'openai',
input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions),
output: formattedOutput,
latency,
baseURL: this.baseURL,
modelParameters: getModelParams(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
},
stopReason: result.status ?? undefined,
tools: availableTools
});
}
return result;
}, async error => {
const httpStatus = error && typeof error === 'object' && 'status' in error ? error.status ?? 500 : 500;
await captureAiGeneration(this.phClient, {
...posthogParams,
model: openAIParams.model,
provider: 'openai',
input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions),
output: [],
latency: 0,
baseURL: this.baseURL,
modelParameters: getModelParams(body),
httpStatus,
usage: {
inputTokens: 0,
outputTokens: 0
},
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 captureAiGeneration(this.phClient, {
...posthogParams,
model: openAIParams.model ?? result.model,
provider: 'openai',
input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions),
output: result.output,
latency,
baseURL: this.baseURL,
modelParameters: getModelParams(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
},
stopReason: result.status ?? undefined
});
return result;
}, async error => {
await captureAiGeneration(this.phClient, {
...posthogParams,
model: openAIParams.model,
provider: 'openai',
input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions),
output: [],
latency: 0,
baseURL: this.baseURL,
modelParameters: getModelParams(body),
usage: {
inputTokens: 0,
outputTokens: 0
},
error
});
throw error;
});
return wrappedPromise;
} finally {
// Restore our wrapped create method
originalSelfRecord['create'] = tempCreate;
}
}
}
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 captureAiGeneration(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,
modelParameters: getModelParams(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 captureAiGeneration(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,
modelParameters: getModelParams(body),
httpStatus,
usage: {
inputTokens: 0
},
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;
}
// --- Overload #1: Non-streaming
// --- Overload #2: Non-streaming
// --- Overload #3: Non-streaming
// --- Overload #4: Non-streaming
// --- Overload #5: Streaming
// --- Overload #6: Streaming
// --- Overload #7: Generic base
// --- 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 = extractAvailableToolCalls('openai', openAIParams);
await captureAiGeneration(this.phClient, {
...posthogParams,
model: openAIParams.model,
provider: 'openai',
input: openAIParams.prompt,
output: finalContent,
latency,
timeToFirstToken,
baseURL: this.baseURL,
modelParameters: getModelParams(body),
httpStatus: 200,
usage,
tools: availableTools
});
} catch (error) {
await captureAiGeneration(this.phClient, {
...posthogParams,
model: openAIParams.model,
provider: 'openai',
input: openAIParams.prompt,
output: [],
latency: 0,
baseURL: this.baseURL,
modelParameters: getModelParams(body),
usage: {
inputTokens: 0,
outputTokens: 0
},
error
});
throw error;
}
})();
return stream2;
}
return value;
});
} else {
const wrappedPromise = parentPromise.then(async result => {
if ('text' in result) {
const latency = (Date.now() - startTime) / 1000;
await captureAiGeneration(this.phClient, {
...posthogParams,
model: openAIParams.model,
provider: 'openai',
input: openAIParams.prompt,
output: result.text,
latency,
baseURL: this.baseURL,
modelParameters: getModelParams(body),
httpStatus: 200,
usage: {
inputTokens: result.usage?.type === 'tokens' ? result.usage.input_tokens ?? 0 : 0,
outputTokens: result.usage?.type === 'tokens' ? result.usage.output_tokens ?? 0 : 0,
rawUsage: result.usage
}
});
return result;
}
}, async error => {
await captureAiGeneration(this.phClient, {
...posthogParams,
model: openAIParams.model,
provider: 'openai',
input: openAIParams.prompt,
output: [],
latency: 0,
baseURL: this.