@posthog/ai
Version:
PostHog Node.js AI integrations
1,013 lines (963 loc) • 34.8 kB
JavaScript
import { v4 } from 'uuid';
import { uuidv7 } from '@posthog/core';
var version = "7.9.2";
// Type guards for safer type checking
const isString = value => {
return typeof value === 'string';
};
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;
}
// 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 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 (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 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();
};
// Union types for dual version support
// Type guards
function isV3Model(model) {
return model.specificationVersion === 'v3';
}
// Content types for the output array
const mapVercelParams = params => {
return {
temperature: params.temperature,
max_output_tokens: params.maxOutputTokens,
top_p: params.topP,
frequency_penalty: params.frequencyPenalty,
presence_penalty: params.presencePenalty,
stop: params.stopSequences,
stream: params.stream
};
};
const mapVercelPrompt = messages => {
// Map and truncate individual content
const inputs = messages.map(message => {
let content;
// Handle system role which has string content
if (message.role === 'system') {
content = [{
type: 'text',
text: truncate(toContentString(message.content))
}];
} else {
// Handle other roles which have array content
if (Array.isArray(message.content)) {
content = message.content.map(c => {
if (c.type === 'text') {
return {
type: 'text',
text: truncate(c.text)
};
} else if (c.type === 'file') {
// For file type, check if it's a data URL and redact if needed
let fileData;
const contentData = c.data;
if (contentData instanceof URL) {
fileData = contentData.toString();
} else if (isString(contentData)) {
// Redact base64 data URLs and raw base64 to prevent oversized events
fileData = redactBase64DataUrl(contentData);
} else {
fileData = 'raw files not supported';
}
return {
type: 'file',
file: fileData,
mediaType: c.mediaType
};
} else if (c.type === 'reasoning') {
return {
type: 'reasoning',
text: truncate(c.reasoning)
};
} else if (c.type === 'tool-call') {
return {
type: 'tool-call',
toolCallId: c.toolCallId,
toolName: c.toolName,
input: c.input
};
} else if (c.type === 'tool-result') {
return {
type: 'tool-result',
toolCallId: c.toolCallId,
toolName: c.toolName,
output: c.output,
isError: c.isError
};
}
return {
type: 'text',
text: ''
};
});
} else {
// Fallback for non-array content
content = [{
type: 'text',
text: truncate(toContentString(message.content))
}];
}
}
return {
role: message.role,
content
};
});
try {
// Trim the inputs array until its JSON size fits within MAX_OUTPUT_SIZE
const encoder = new TextEncoder();
let serialized = JSON.stringify(inputs);
let removedCount = 0;
// We need to keep track of the initial size of the inputs array because we're going to be mutating it
const initialSize = inputs.length;
for (let i = 0; i < initialSize && encoder.encode(serialized).byteLength > MAX_OUTPUT_SIZE; i++) {
inputs.shift();
removedCount++;
serialized = JSON.stringify(inputs);
}
if (removedCount > 0) {
// Add one placeholder to indicate how many were removed
inputs.unshift({
role: 'posthog',
content: `[${removedCount} message${removedCount === 1 ? '' : 's'} removed due to size limit]`
});
}
} catch (error) {
console.error('Error stringifying inputs', error);
return [{
role: 'posthog',
content: 'An error occurred while processing your request. Please try again.'
}];
}
return inputs;
};
const mapVercelOutput = result => {
const content = result.map(item => {
if (item.type === 'text') {
return {
type: 'text',
text: truncate(item.text)
};
}
if (item.type === 'tool-call') {
return {
type: 'tool-call',
id: item.toolCallId,
function: {
name: item.toolName,
arguments: item.args || JSON.stringify(item.arguments || {})
}
};
}
if (item.type === 'reasoning') {
return {
type: 'reasoning',
text: truncate(item.text)
};
}
if (item.type === 'file') {
// Handle files similar to input mapping - avoid large base64 data
let fileData;
if (item.data instanceof URL) {
fileData = item.data.toString();
} else if (typeof item.data === 'string') {
fileData = redactBase64DataUrl(item.data);
// If not redacted and still large, replace with size indicator
if (fileData === item.data && item.data.length > 1000) {
fileData = `[${item.mediaType} file - ${item.data.length} bytes]`;
}
} else {
fileData = `[binary ${item.mediaType} file]`;
}
return {
type: 'file',
name: 'generated_file',
mediaType: item.mediaType,
data: fileData
};
}
if (item.type === 'source') {
return {
type: 'source',
sourceType: item.sourceType,
id: item.id,
url: item.url || '',
title: item.title || ''
};
}
// Fallback for unknown types - try to extract text if possible
return {
type: 'text',
text: truncate(JSON.stringify(item))
};
});
if (content.length > 0) {
return [{
role: 'assistant',
content: content.length === 1 && content[0].type === 'text' ? content[0].text : content
}];
}
// otherwise stringify and truncate
try {
const jsonOutput = JSON.stringify(result);
return [{
content: truncate(jsonOutput),
role: 'assistant'
}];
} catch {
console.error('Error stringifying output');
return [];
}
};
const extractProvider = model => {
const provider = model.provider.toLowerCase();
const providerName = provider.split('.')[0];
return providerName;
};
// Extract web search count from provider metadata (works for both V2 and V3)
const extractWebSearchCount = (providerMetadata, usage) => {
// Try Anthropic-specific extraction
if (providerMetadata && typeof providerMetadata === 'object' && 'anthropic' in providerMetadata && providerMetadata.anthropic && typeof providerMetadata.anthropic === 'object' && 'server_tool_use' in providerMetadata.anthropic) {
const serverToolUse = providerMetadata.anthropic.server_tool_use;
if (serverToolUse && typeof serverToolUse === 'object' && 'web_search_requests' in serverToolUse && typeof serverToolUse.web_search_requests === 'number') {
return serverToolUse.web_search_requests;
}
}
// Fall back to generic calculation
return calculateWebSearchCount({
usage,
providerMetadata
});
};
// Extract additional token values from provider metadata
const extractAdditionalTokenValues = providerMetadata => {
if (providerMetadata && typeof providerMetadata === 'object' && 'anthropic' in providerMetadata && providerMetadata.anthropic && typeof providerMetadata.anthropic === 'object' && 'cacheCreationInputTokens' in providerMetadata.anthropic) {
return {
cacheCreationInputTokens: providerMetadata.anthropic.cacheCreationInputTokens
};
}
return {};
};
// For Anthropic providers in V3, inputTokens.total is the sum of all tokens (uncached + cache read + cache write).
// Our cost calculation expects inputTokens to be only the uncached portion for Anthropic.
// This helper subtracts cache tokens from inputTokens for Anthropic V3 models.
const adjustAnthropicV3CacheTokens = (model, provider, usage) => {
if (isV3Model(model) && provider.toLowerCase().includes('anthropic')) {
const cacheReadTokens = usage.cacheReadInputTokens || 0;
const cacheWriteTokens = usage.cacheCreationInputTokens || 0;
const cacheTokens = cacheReadTokens + cacheWriteTokens;
if (usage.inputTokens && cacheTokens > 0) {
usage.inputTokens = Math.max(usage.inputTokens - cacheTokens, 0);
}
}
};
// Helper to extract numeric token value from V2 (number) or V3 (object with .total) usage formats
const extractTokenCount = value => {
if (typeof value === 'number') {
return value;
}
if (value && typeof value === 'object' && 'total' in value && typeof value.total === 'number') {
return value.total;
}
return undefined;
};
// Helper to extract reasoning tokens from V2 (usage.reasoningTokens) or V3 (usage.outputTokens.reasoning)
const extractReasoningTokens = usage => {
// V2 style: top-level reasoningTokens
if ('reasoningTokens' in usage) {
return usage.reasoningTokens;
}
// V3 style: nested in outputTokens.reasoning
if ('outputTokens' in usage && usage.outputTokens && typeof usage.outputTokens === 'object' && 'reasoning' in usage.outputTokens) {
return usage.outputTokens.reasoning;
}
return undefined;
};
// Helper to extract cached input tokens from V2 (usage.cachedInputTokens) or V3 (usage.inputTokens.cacheRead)
const extractCacheReadTokens = usage => {
// V2 style: top-level cachedInputTokens
if ('cachedInputTokens' in usage) {
return usage.cachedInputTokens;
}
// V3 style: nested in inputTokens.cacheRead
if ('inputTokens' in usage && usage.inputTokens && typeof usage.inputTokens === 'object' && 'cacheRead' in usage.inputTokens) {
return usage.inputTokens.cacheRead;
}
return undefined;
};
/**
* Wraps a Vercel AI SDK language model (V2 or V3) with PostHog tracing.
* Automatically detects the model version and applies appropriate instrumentation.
*/
const wrapVercelLanguageModel = (model, phClient, options) => {
const traceId = options.posthogTraceId ?? v4();
const mergedOptions = {
...options,
posthogTraceId: traceId,
posthogDistinctId: options.posthogDistinctId,
posthogProperties: {
...options.posthogProperties,
$ai_framework: 'vercel',
$ai_framework_version: model.specificationVersion === 'v3' ? '6' : '5'
}
};
// Create wrapped model using Object.create to preserve the prototype chain
// This automatically inherits all properties (including getters) from the model
const wrappedModel = Object.create(model, {
doGenerate: {
value: async params => {
const startTime = Date.now();
const mergedParams = {
...mergedOptions,
...mapVercelParams(params)
};
const availableTools = extractAvailableToolCalls('vercel', params);
try {
const result = await model.doGenerate(params);
const modelId = mergedOptions.posthogModelOverride ?? (result.response?.modelId ? result.response.modelId : model.modelId);
const provider = mergedOptions.posthogProviderOverride ?? extractProvider(model);
const baseURL = ''; // cannot currently get baseURL from vercel
const content = mapVercelOutput(result.content);
const latency = (Date.now() - startTime) / 1000;
const providerMetadata = result.providerMetadata;
const additionalTokenValues = extractAdditionalTokenValues(providerMetadata);
const webSearchCount = extractWebSearchCount(providerMetadata, result.usage);
// V2 usage has simple numbers, V3 has objects with .total - normalize both
const usageObj = result.usage;
// Extract raw response for providers that include detailed usage metadata
// For Gemini, candidatesTokensDetails is in result.response.body.usageMetadata
const rawUsageData = {
usage: result.usage,
providerMetadata
};
// Include response body usageMetadata if it contains detailed token breakdown (e.g., candidatesTokensDetails)
if (result.response && typeof result.response === 'object') {
const responseBody = result.response.body;
if (responseBody && typeof responseBody === 'object' && 'usageMetadata' in responseBody) {
rawUsageData.rawResponse = {
usageMetadata: responseBody.usageMetadata
};
}
}
const usage = {
inputTokens: extractTokenCount(result.usage.inputTokens),
outputTokens: extractTokenCount(result.usage.outputTokens),
reasoningTokens: extractReasoningTokens(usageObj),
cacheReadInputTokens: extractCacheReadTokens(usageObj),
webSearchCount,
...additionalTokenValues,
rawUsage: rawUsageData
};
adjustAnthropicV3CacheTokens(model, provider, usage);
await sendEventToPosthog({
client: phClient,
distinctId: mergedOptions.posthogDistinctId,
traceId: mergedOptions.posthogTraceId ?? v4(),
model: modelId,
provider: provider,
input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt),
output: content,
latency,
baseURL,
params: mergedParams,
httpStatus: 200,
usage,
tools: availableTools,
captureImmediate: mergedOptions.posthogCaptureImmediate
});
return result;
} catch (error) {
const modelId = model.modelId;
const enrichedError = await sendEventWithErrorToPosthog({
client: phClient,
distinctId: mergedOptions.posthogDistinctId,
traceId: mergedOptions.posthogTraceId ?? v4(),
model: modelId,
provider: model.provider,
input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt),
output: [],
latency: 0,
baseURL: '',
params: mergedParams,
usage: {
inputTokens: 0,
outputTokens: 0
},
error: error,
tools: availableTools,
captureImmediate: mergedOptions.posthogCaptureImmediate
});
throw enrichedError;
}
},
writable: true,
configurable: true,
enumerable: false
},
doStream: {
value: async params => {
const startTime = Date.now();
let firstTokenTime;
let generatedText = '';
let reasoningText = '';
let usage = {};
let providerMetadata = undefined;
const mergedParams = {
...mergedOptions,
...mapVercelParams(params)
};
const modelId = mergedOptions.posthogModelOverride ?? model.modelId;
const provider = mergedOptions.posthogProviderOverride ?? extractProvider(model);
const availableTools = extractAvailableToolCalls('vercel', params);
const baseURL = ''; // cannot currently get baseURL from vercel
// Map to track in-progress tool calls
const toolCallsInProgress = new Map();
try {
const {
stream,
...rest
} = await model.doStream(params);
const transformStream = new TransformStream({
transform(chunk, controller) {
// Handle streaming patterns - compatible with both V2 and V3
if (chunk.type === 'text-delta') {
if (firstTokenTime === undefined) {
firstTokenTime = Date.now();
}
generatedText += chunk.delta;
}
if (chunk.type === 'reasoning-delta') {
if (firstTokenTime === undefined) {
firstTokenTime = Date.now();
}
reasoningText += chunk.delta;
}
// Handle tool call chunks
if (chunk.type === 'tool-input-start') {
if (firstTokenTime === undefined) {
firstTokenTime = Date.now();
}
// Initialize a new tool call
toolCallsInProgress.set(chunk.id, {
toolCallId: chunk.id,
toolName: chunk.toolName,
input: ''
});
}
if (chunk.type === 'tool-input-delta') {
// Accumulate tool call arguments
const toolCall = toolCallsInProgress.get(chunk.id);
if (toolCall) {
toolCall.input += chunk.delta;
}
}
if (chunk.type === 'tool-input-end') {
// Tool call is complete, keep it in the map for final processing
}
if (chunk.type === 'tool-call') {
if (firstTokenTime === undefined) {
firstTokenTime = Date.now();
}
// Direct tool call chunk (complete tool call)
toolCallsInProgress.set(chunk.toolCallId, {
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
input: chunk.input
});
}
if (chunk.type === 'finish') {
providerMetadata = chunk.providerMetadata;
const additionalTokenValues = extractAdditionalTokenValues(providerMetadata);
const chunkUsage = chunk.usage || {};
usage = {
inputTokens: extractTokenCount(chunk.usage?.inputTokens),
outputTokens: extractTokenCount(chunk.usage?.outputTokens),
reasoningTokens: extractReasoningTokens(chunkUsage),
cacheReadInputTokens: extractCacheReadTokens(chunkUsage),
...additionalTokenValues
};
}
controller.enqueue(chunk);
},
flush: async () => {
const latency = (Date.now() - startTime) / 1000;
const timeToFirstToken = firstTokenTime !== undefined ? (firstTokenTime - startTime) / 1000 : undefined;
// Build content array similar to mapVercelOutput structure
const content = [];
if (reasoningText) {
content.push({
type: 'reasoning',
text: truncate(reasoningText)
});
}
if (generatedText) {
content.push({
type: 'text',
text: truncate(generatedText)
});
}
// Add completed tool calls to content
for (const toolCall of toolCallsInProgress.values()) {
if (toolCall.toolName) {
content.push({
type: 'tool-call',
id: toolCall.toolCallId,
function: {
name: toolCall.toolName,
arguments: toolCall.input
}
});
}
}
// Structure output like mapVercelOutput does
const output = content.length > 0 ? [{
role: 'assistant',
content: content.length === 1 && content[0].type === 'text' ? content[0].text : content
}] : [];
const webSearchCount = extractWebSearchCount(providerMetadata, usage);
// Update usage with web search count and raw metadata
const finalUsage = {
...usage,
webSearchCount,
rawUsage: {
usage,
providerMetadata
}
};
adjustAnthropicV3CacheTokens(model, provider, finalUsage);
await sendEventToPosthog({
client: phClient,
distinctId: mergedOptions.posthogDistinctId,
traceId: mergedOptions.posthogTraceId ?? v4(),
model: modelId,
provider: provider,
input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt),
output: output,
latency,
timeToFirstToken,
baseURL,
params: mergedParams,
httpStatus: 200,
usage: finalUsage,
tools: availableTools,
captureImmediate: mergedOptions.posthogCaptureImmediate
});
}
});
return {
stream: stream.pipeThrough(transformStream),
...rest
};
} catch (error) {
const enrichedError = await sendEventWithErrorToPosthog({
client: phClient,
distinctId: mergedOptions.posthogDistinctId,
traceId: mergedOptions.posthogTraceId ?? v4(),
model: modelId,
provider: provider,
input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt),
output: [],
latency: 0,
baseURL: '',
params: mergedParams,
usage: {
inputTokens: 0,
outputTokens: 0
},
error: error,
tools: availableTools,
captureImmediate: mergedOptions.posthogCaptureImmediate
});
throw enrichedError;
}
},
writable: true,
configurable: true,
enumerable: false
}
});
return wrappedModel;
};
export { wrapVercelLanguageModel as withTracing };
//# sourceMappingURL=index.mjs.map