UNPKG

@posthog/ai

Version:
1,422 lines (1,367 loc) 50.7 kB
'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.