UNPKG

@posthog/ai

Version:
1,470 lines (1,464 loc) 184 kB
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