@posthog/ai
Version:
PostHog Node.js AI integrations
940 lines (900 loc) • 34.7 kB
JavaScript
'use strict';
require('uuid');
var base = require('@langchain/core/callbacks/base');
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 sanitizeLangChain = data => redactor.redact(data);
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 withPrivacyMode = (client, privacyMode, input) => {
return client.privacy_mode || privacyMode ? null : input;
};
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;
}
var version = "8.3.0";
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');
}
// Warn when a wrapper's base_url points at the PostHog AI Gateway: the gateway
// emits its own $ai_generation, so each call would be captured (and, for billable
// products, billed) twice. We only warn — the wrapper's event carries data the
// gateway never sees (groups, custom properties, trace hierarchy).
// Keep in sync with the gateway's deployed hosts (see services/llm-gateway in the
// main repo). gateway.us.posthog.com is live today; the rest are listed ahead of
// any traffic moving to them.
const POSTHOG_AI_GATEWAY_HOSTS = ['gateway.posthog.com', 'gateway.us.posthog.com', 'gateway.eu.posthog.com', 'ai-gateway.us.posthog.com', 'ai-gateway.eu.posthog.com'];
// Swap for the dedicated AI Gateway page once it ships.
const GATEWAY_DOCS_URL = 'https://posthog.com/docs/ai-observability';
const extractHost = baseURL => {
try {
// Tolerate bare hosts that omit a scheme, e.g. "gateway.us.posthog.com/v1".
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(baseURL);
return new URL(hasScheme ? baseURL : `https://${baseURL}`).hostname.toLowerCase();
} catch {
return undefined;
}
};
const isPostHogAiGatewayUrl = baseURL => {
if (!baseURL) {
return false;
}
const host = extractHost(baseURL);
return host !== undefined && POSTHOG_AI_GATEWAY_HOSTS.includes(host);
};
// Warns on every gateway call by design: the misconfiguration is impossible to
// miss that way, and a doubled bill is worse than noisy logs.
const warnIfPostHogAiGateway = baseURL => {
if (!isPostHogAiGatewayUrl(baseURL)) {
return;
}
console.warn('[PostHog] The PostHog AI wrapper is pointed at the PostHog AI Gateway. ' + 'Both capture $ai_generation, so every call is double-counted and double-billed. ' + `Use one or the other — see ${GATEWAY_DOCS_URL}.`);
};
/** A run may either be a Span or a Generation */
/** Storage for run metadata */
class LangChainCallbackHandler extends base.BaseCallbackHandler {
name = 'PosthogCallbackHandler';
runs = {};
parentTree = {};
constructor(options) {
if (!options.client) {
throw new Error('PostHog client is required');
}
super();
this.client = options.client;
this.distinctId = options.distinctId;
this.traceId = options.traceId;
this.properties = options.properties || {};
this.privacyMode = options.privacyMode || false;
this.groups = options.groups || {};
this.debug = options.debug || false;
}
// ===== CALLBACK METHODS =====
handleChainStart(chain, inputs, runId, parentRunId, tags, metadata, _runType, runName) {
this._logDebugEvent('on_chain_start', runId, parentRunId, {
inputs,
tags
});
this._setParentOfRun(runId, parentRunId);
this._setTraceOrSpanMetadata(chain, inputs, runId, parentRunId, metadata, tags, runName);
}
handleChainEnd(outputs, runId, parentRunId, tags, _kwargs) {
this._logAndPopTraceOrSpan('on_chain_end', runId, parentRunId, {
outputs,
tags
}, outputs);
}
handleChainError(error, runId, parentRunId, tags, _kwargs) {
this._logAndPopTraceOrSpan('on_chain_error', runId, parentRunId, {
error,
tags
}, error);
}
handleChatModelStart(serialized, messages, runId, parentRunId, extraParams, tags, metadata, runName) {
this._logDebugEvent('on_chat_model_start', runId, parentRunId, {
messages,
tags
});
this._setParentOfRun(runId, parentRunId);
// Flatten the two-dimensional messages and convert each message to a plain object
const input = messages.flat().map(m => this._convertMessageToDict(m));
this._setLLMMetadata(serialized, runId, input, metadata, extraParams, runName);
}
handleLLMStart(serialized, prompts, runId, parentRunId, extraParams, tags, metadata, runName) {
this._logDebugEvent('on_llm_start', runId, parentRunId, {
prompts,
tags
});
this._setParentOfRun(runId, parentRunId);
this._setLLMMetadata(serialized, runId, prompts, metadata, extraParams, runName);
}
handleLLMEnd(output, runId, parentRunId, tags, _extraParams) {
this._logAndPopGeneration('on_llm_end', runId, parentRunId, {
output,
tags
}, output);
}
handleLLMError(err, runId, parentRunId, tags, _extraParams) {
this._logAndPopGeneration('on_llm_error', runId, parentRunId, {
err,
tags
}, err);
}
handleToolStart(tool, input, runId, parentRunId, tags, metadata, runName) {
this._logAndSetTraceOrSpan('on_tool_start', tool, input, runId, parentRunId, {
input,
tags
}, tags, metadata, runName);
}
handleToolEnd(output, runId, parentRunId, tags) {
this._logAndPopTraceOrSpan('on_tool_end', runId, parentRunId, {
output,
tags
}, output);
}
handleToolError(err, runId, parentRunId, tags) {
this._logAndPopTraceOrSpan('on_tool_error', runId, parentRunId, {
err,
tags
}, err);
}
handleRetrieverStart(retriever, query, runId, parentRunId, tags, metadata, name) {
this._logAndSetTraceOrSpan('on_retriever_start', retriever, query, runId, parentRunId, {
query,
tags
}, tags, metadata, name);
}
handleRetrieverEnd(documents, runId, parentRunId, tags) {
this._logAndPopTraceOrSpan('on_retriever_end', runId, parentRunId, {
documents,
tags
}, documents);
}
handleRetrieverError(err, runId, parentRunId, tags) {
this._logAndPopTraceOrSpan('on_retriever_error', runId, parentRunId, {
err,
tags
}, err);
}
handleAgentAction(action, runId, parentRunId, tags) {
this._logDebugEvent('on_agent_action', runId, parentRunId, {
action,
tags
});
this._setParentOfRun(runId, parentRunId);
this._setTraceOrSpanMetadata(null, action, runId, parentRunId);
}
handleAgentEnd(action, runId, parentRunId, tags) {
this._logDebugEvent('on_agent_finish', runId, parentRunId, {
action,
tags
});
this._popRunAndCaptureTraceOrSpan(runId, parentRunId, action);
}
// ===== PRIVATE HELPERS =====
_logAndSetTraceOrSpan(eventName, serialized, input, runId, parentRunId, debugPayload, tags, metadata, runName) {
this._logDebugEvent(eventName, runId, parentRunId, debugPayload);
this._setParentOfRun(runId, parentRunId);
this._setTraceOrSpanMetadata(serialized, input, runId, parentRunId, metadata, tags, runName);
}
_logAndPopTraceOrSpan(eventName, runId, parentRunId, debugPayload, result) {
this._logDebugEvent(eventName, runId, parentRunId, debugPayload);
this._popRunAndCaptureTraceOrSpan(runId, parentRunId, result);
}
_logAndPopGeneration(eventName, runId, parentRunId, debugPayload, result) {
this._logDebugEvent(eventName, runId, parentRunId, debugPayload);
this._popRunAndCaptureGeneration(runId, parentRunId, result);
}
_setParentOfRun(runId, parentRunId) {
if (parentRunId) {
this.parentTree[runId] = parentRunId;
}
}
_popParentOfRun(runId) {
delete this.parentTree[runId];
}
_findRootRun(runId) {
let id = runId;
while (this.parentTree[id]) {
id = this.parentTree[id];
}
return id;
}
_setTraceOrSpanMetadata(serialized, input, runId, parentRunId, ...args) {
// Use default names if not provided: if this is a top-level run, we mark it as a trace, otherwise as a span.
const defaultName = parentRunId ? 'span' : 'trace';
const runName = this._getLangchainRunName(serialized, ...args) || defaultName;
this.runs[runId] = {
name: runName,
input,
startTime: Date.now()
};
}
_setLLMMetadata(serialized, runId, messages, metadata, extraParams, runName) {
const runNameFound = this._getLangchainRunName(serialized, {
extraParams,
runName
}) || 'generation';
const generation = {
name: runNameFound,
input: sanitizeLangChain(messages),
startTime: Date.now()
};
if (extraParams) {
generation.modelParams = getModelParams(extraParams.invocation_params);
if (extraParams.invocation_params && extraParams.invocation_params.tools) {
generation.tools = extraParams.invocation_params.tools;
}
}
if (metadata) {
if (metadata.ls_model_name) {
generation.model = metadata.ls_model_name;
}
if (metadata.ls_provider) {
generation.provider = metadata.ls_provider;
}
}
if (serialized && 'kwargs' in serialized && serialized.kwargs.openai_api_base) {
generation.baseUrl = serialized.kwargs.openai_api_base;
}
this.runs[runId] = generation;
}
_popRunMetadata(runId) {
const endTime = Date.now();
const run = this.runs[runId];
if (!run) {
console.warn(`No run metadata found for run ${runId}`);
return undefined;
}
run.endTime = endTime;
delete this.runs[runId];
return run;
}
_getTraceId(runId) {
return this.traceId ? String(this.traceId) : this._findRootRun(runId);
}
_getParentRunId(traceId, _runId, parentRunId) {
// Replace the parent-run if not found in our stored parent tree.
if (parentRunId && !this.parentTree[parentRunId]) {
return traceId;
}
return parentRunId;
}
_popRunAndCaptureTraceOrSpan(runId, parentRunId, outputs) {
const traceId = this._getTraceId(runId);
this._popParentOfRun(runId);
const run = this._popRunMetadata(runId);
if (!run) {
return;
}
if ('modelParams' in run) {
console.warn(`Run ${runId} is a generation, but attempted to be captured as a trace/span.`);
return;
}
const actualParentRunId = this._getParentRunId(traceId, runId, parentRunId);
this._captureTraceOrSpan(traceId, runId, run, outputs, actualParentRunId);
}
_captureTraceOrSpan(traceId, runId, run, outputs, parentRunId) {
const eventName = parentRunId ? '$ai_span' : '$ai_trace';
const latency = run.endTime ? (run.endTime - run.startTime) / 1000 : 0;
const eventProperties = {
$ai_lib: 'posthog-ai',
$ai_lib_version: version,
$ai_trace_id: traceId,
$ai_input_state: withPrivacyMode(this.client, this.privacyMode, run.input),
$ai_latency: latency,
$ai_span_name: run.name,
$ai_span_id: runId,
$ai_framework: 'langchain'
};
if (parentRunId) {
eventProperties['$ai_parent_id'] = parentRunId;
}
Object.assign(eventProperties, this.properties);
if (!this.distinctId) {
eventProperties['$process_person_profile'] = false;
}
if (outputs instanceof Error) {
eventProperties['$ai_error'] = stringifyError(outputs);
eventProperties['$ai_is_error'] = true;
} else if (outputs !== undefined) {
eventProperties['$ai_output_state'] = withPrivacyMode(this.client, this.privacyMode, outputs);
}
this.client.capture({
distinctId: this.distinctId ? this.distinctId.toString() : runId,
event: eventName,
properties: eventProperties,
groups: this.groups
});
}
_popRunAndCaptureGeneration(runId, parentRunId, response) {
const traceId = this._getTraceId(runId);
this._popParentOfRun(runId);
const run = this._popRunMetadata(runId);
if (!run || typeof run !== 'object' || !('modelParams' in run)) {
console.warn(`Run ${runId} is not a generation, but attempted to be captured as such.`);
return;
}
const actualParentRunId = this._getParentRunId(traceId, runId, parentRunId);
this._captureGeneration(traceId, runId, run, response, actualParentRunId);
}
_captureGeneration(traceId, runId, run, output, parentRunId) {
const latency = run.endTime ? (run.endTime - run.startTime) / 1000 : 0;
warnIfPostHogAiGateway(run.baseUrl);
const eventProperties = {
$ai_lib: 'posthog-ai',
$ai_lib_version: version,
$ai_trace_id: traceId,
$ai_span_id: runId,
$ai_span_name: run.name,
$ai_parent_id: parentRunId,
$ai_provider: run.provider,
$ai_model: run.model,
$ai_model_parameters: run.modelParams,
$ai_input: withPrivacyMode(this.client, this.privacyMode, run.input),
$ai_http_status: 200,
$ai_latency: latency,
$ai_base_url: run.baseUrl,
$ai_framework: 'langchain'
};
if (run.tools) {
eventProperties['$ai_tools'] = run.tools;
}
if (output instanceof Error) {
eventProperties['$ai_http_status'] = output.status || 500;
eventProperties['$ai_error'] = stringifyError(output);
eventProperties['$ai_is_error'] = true;
} else {
// Handle token usage
const [inputTokens, outputTokens, additionalTokenData] = this.parseUsage(output, run.provider, run.model);
eventProperties['$ai_input_tokens'] = inputTokens;
eventProperties['$ai_output_tokens'] = outputTokens;
// Add additional token data to properties
if (additionalTokenData.cacheReadInputTokens) {
eventProperties['$ai_cache_read_input_tokens'] = additionalTokenData.cacheReadInputTokens;
}
if (additionalTokenData.cacheWriteInputTokens) {
eventProperties['$ai_cache_creation_input_tokens'] = additionalTokenData.cacheWriteInputTokens;
}
if (additionalTokenData.reasoningTokens) {
eventProperties['$ai_reasoning_tokens'] = additionalTokenData.reasoningTokens;
}
if (additionalTokenData.webSearchCount !== undefined) {
eventProperties['$ai_web_search_count'] = additionalTokenData.webSearchCount;
}
// Extract stop reason from generation info
const stopReason = this._extractStopReason(output);
if (stopReason) {
eventProperties['$ai_stop_reason'] = stopReason;
}
// Handle generations/completions
let completions;
if (output.generations && Array.isArray(output.generations)) {
const lastGeneration = output.generations[output.generations.length - 1];
if (Array.isArray(lastGeneration) && lastGeneration.length > 0) {
// Check if this is a ChatGeneration by looking at the first item
const isChatGeneration = 'message' in lastGeneration[0] && lastGeneration[0].message;
if (isChatGeneration) {
// For ChatGeneration, convert messages to dict format
completions = lastGeneration.map(gen => {
return this._convertMessageToDict(gen.message);
});
} else {
// For non-ChatGeneration, extract raw response
completions = lastGeneration.map(gen => {
return this._extractRawResponse(gen);
});
}
}
}
if (completions) {
eventProperties['$ai_output_choices'] = withPrivacyMode(this.client, this.privacyMode, completions);
}
}
Object.assign(eventProperties, this.properties);
if (!this.distinctId) {
eventProperties['$process_person_profile'] = false;
}
this.client.capture({
distinctId: this.distinctId ? this.distinctId.toString() : traceId,
event: '$ai_generation',
properties: eventProperties,
groups: this.groups
});
}
_logDebugEvent(eventName, runId, parentRunId, extra) {
if (this.debug) {
console.log(`Event: ${eventName}, runId: ${runId}, parentRunId: ${parentRunId}, extra:`, extra);
}
}
_getLangchainRunName(serialized, ...args) {
if (args && args.length > 0) {
for (const arg of args) {
if (arg && typeof arg === 'object' && 'name' in arg) {
return arg.name;
} else if (arg && typeof arg === 'object' && 'runName' in arg) {
return arg.runName;
}
}
}
if (serialized && serialized.name) {
return serialized.name;
}
if (serialized && serialized.id) {
return Array.isArray(serialized.id) ? serialized.id[serialized.id.length - 1] : serialized.id;
}
return undefined;
}
_convertLcToolCallsToOai(toolCalls) {
return toolCalls.map(toolCall => ({
type: 'function',
id: toolCall.id,
function: {
name: toolCall.name,
arguments: JSON.stringify(toolCall.args)
}
}));
}
_extractRawResponse(generation) {
// Extract the response from the last response of the LLM call
// We return the text of the response if not empty
if (generation.text != null && generation.text.trim() !== '') {
return generation.text.trim();
} else if (generation.message) {
// Additional kwargs contains the response in case of tool usage
return generation.message.additional_kwargs || generation.message.additionalKwargs || {};
} else {
// Not tool usage, some LLM responses can be simply empty
return '';
}
}
_convertMessageToDict(message) {
let messageDict = {};
const messageType = message.getType();
switch (messageType) {
case 'human':
messageDict = {
role: 'user',
content: message.content
};
break;
case 'ai':
messageDict = {
role: 'assistant',
content: message.content
};
if (message.tool_calls) {
messageDict.tool_calls = this._convertLcToolCallsToOai(message.tool_calls);
}
break;
case 'system':
messageDict = {
role: 'system',
content: message.content
};
break;
case 'tool':
messageDict = {
role: 'tool',
content: message.content
};
break;
case 'function':
messageDict = {
role: 'function',
content: message.content
};
break;
default:
messageDict = {
role: messageType,
content: toContentString(message.content)
};
break;
}
if (message.additional_kwargs) {
messageDict = {
...messageDict,
...message.additional_kwargs
};
}
// Sanitize the message content to redact base64 images
return sanitizeLangChain(messageDict);
}
_extractStopReason(output) {
if (!output.generations || !Array.isArray(output.generations)) {
return undefined;
}
const lastGeneration = output.generations[output.generations.length - 1];
if (!Array.isArray(lastGeneration) || lastGeneration.length === 0) {
return undefined;
}
const gen = lastGeneration[0];
// Check generationInfo for finish_reason (OpenAI format)
if (gen.generationInfo?.finish_reason) {
return String(gen.generationInfo.finish_reason);
}
// Check generationInfo for response_metadata.stop_reason (Anthropic format)
if (gen.generationInfo?.response_metadata?.stop_reason) {
return String(gen.generationInfo.response_metadata.stop_reason);
}
// Check message response_metadata for finish_reason (common LangChain format)
if (gen.generationInfo?.response_metadata?.finish_reason) {
return String(gen.generationInfo.response_metadata.finish_reason);
}
// Check for stop_reason directly in generationInfo
if (gen.generationInfo?.stop_reason) {
return String(gen.generationInfo.stop_reason);
}
return undefined;
}
_parseUsageModel(usage, provider, model) {
const conversionList = [['promptTokens', 'input'], ['completionTokens', 'output'], ['input_tokens', 'input'], ['output_tokens', 'output'], ['prompt_token_count', 'input'], ['candidates_token_count', 'output'], ['inputTokenCount', 'input'], ['outputTokenCount', 'output'], ['input_token_count', 'input'], ['generated_token_count', 'output']];
const parsedUsage = conversionList.reduce((acc, [modelKey, typeKey]) => {
const value = usage[modelKey];
if (value != null) {
const finalCount = Array.isArray(value) ? value.reduce((sum, tokenCount) => sum + tokenCount, 0) : value;
acc[typeKey] = finalCount;
}
return acc;
}, {
input: 0,
output: 0
});
// Extract additional token details like cached tokens and reasoning tokens
const additionalTokenData = {};
// Check for cached tokens in various formats
if (usage.prompt_tokens_details?.cached_tokens != null) {
additionalTokenData.cacheReadInputTokens = usage.prompt_tokens_details.cached_tokens;
} else if (usage.input_token_details?.cache_read != null) {
additionalTokenData.cacheReadInputTokens = usage.input_token_details.cache_read;
} else if (usage.cachedPromptTokens != null) {
additionalTokenData.cacheReadInputTokens = usage.cachedPromptTokens;
} else if (usage.cache_read_input_tokens != null) {
additionalTokenData.cacheReadInputTokens = usage.cache_read_input_tokens;
}
// Check for cache write/creation tokens in various formats
if (usage.cache_creation_input_tokens != null) {
additionalTokenData.cacheWriteInputTokens = usage.cache_creation_input_tokens;
} else if (usage.input_token_details?.cache_creation != null) {
additionalTokenData.cacheWriteInputTokens = usage.input_token_details.cache_creation;
}
// Check for reasoning tokens in various formats
if (usage.completion_tokens_details?.reasoning_tokens != null) {
additionalTokenData.reasoningTokens = usage.completion_tokens_details.reasoning_tokens;
} else if (usage.output_token_details?.reasoning != null) {
additionalTokenData.reasoningTokens = usage.output_token_details.reasoning;
} else if (usage.reasoningTokens != null) {
additionalTokenData.reasoningTokens = usage.reasoningTokens;
}
// Extract web search counts from various provider formats
let webSearchCount;
// Priority 1: Exact Count
// Check Anthropic format (server_tool_use.web_search_requests)
if (usage.server_tool_use?.web_search_requests !== undefined) {
webSearchCount = usage.server_tool_use.web_search_requests;
}
// Priority 2: Binary Detection (1 or 0)
// Check for citations array (Perplexity)
else if (usage.citations && Array.isArray(usage.citations) && usage.citations.length > 0) {
webSearchCount = 1;
}
// Check for search_results array (Perplexity via OpenRouter)
else if (usage.search_results && Array.isArray(usage.search_results) && usage.search_results.length > 0) {
webSearchCount = 1;
}
// Check for search_context_size (Perplexity via OpenRouter)
else if (usage.search_context_size) {
webSearchCount = 1;
}
// Check for annotations with url_citation type
else if (usage.annotations && Array.isArray(usage.annotations)) {
const hasUrlCitation = usage.annotations.some(ann => {
return ann && typeof ann === 'object' && 'type' in ann && ann.type === 'url_citation';
});
if (hasUrlCitation) {
webSearchCount = 1;
}
}
// Check Gemini format (grounding metadata - binary 0 or 1)
else if (usage.grounding_metadata?.grounding_support !== undefined || usage.grounding_metadata?.web_search_queries !== undefined) {
webSearchCount = 1;
}
if (webSearchCount !== undefined) {
additionalTokenData.webSearchCount = webSearchCount;
}
// For Anthropic providers, LangChain reports input_tokens as the sum of all input tokens.
// Our cost calculation expects them to be separate for Anthropic, so we subtract cache tokens.
// Both cache_read and cache_write tokens should be subtracted since Anthropic's raw API
// reports input_tokens as tokens NOT read from or used to create a cache.
// For other providers (OpenAI, etc.), input_tokens already excludes cache tokens as expected.
// Match logic consistent with plugin-server: exact match on provider OR substring match on model
let isAnthropic = false;
if (provider && provider.toLowerCase() === 'anthropic') {
isAnthropic = true;
} else if (model && model.toLowerCase().includes('anthropic')) {
isAnthropic = true;
}
if (isAnthropic && parsedUsage.input) {
const cacheTokens = (additionalTokenData.cacheReadInputTokens || 0) + (additionalTokenData.cacheWriteInputTokens || 0);
if (cacheTokens > 0) {
parsedUsage.input = Math.max(parsedUsage.input - cacheTokens, 0);
}
}
return [parsedUsage.input, parsedUsage.output, additionalTokenData];
}
parseUsage(response, provider, model) {
let llmUsage = [0, 0, {}];
const llmUsageKeys = ['token_usage', 'usage', 'tokenUsage'];
if (response.llmOutput != null) {
const key = llmUsageKeys.find(k => response.llmOutput?.[k] != null);
if (key) {
llmUsage = this._parseUsageModel(response.llmOutput[key], provider, model);
}
}
// If top-level usage info was not found, try checking the generations.
if (llmUsage[0] === 0 && llmUsage[1] === 0 && response.generations) {
for (const generation of response.generations) {
for (const genChunk of generation) {
// Check other paths for usage information
if (genChunk.generationInfo?.usage_metadata) {
llmUsage = this._parseUsageModel(genChunk.generationInfo.usage_metadata, provider, model);
return llmUsage;
}
const messageChunk = genChunk.generationInfo ?? {};
const responseMetadata = messageChunk.response_metadata ?? {};
const chunkUsage = responseMetadata['usage'] ?? responseMetadata['amazon-bedrock-invocationMetrics'] ?? messageChunk.usage_metadata;
if (chunkUsage) {
llmUsage = this._parseUsageModel(chunkUsage, provider, model);
return llmUsage;
}
}
}
}
return llmUsage;
}
}
exports.LangChainCallbackHandler = LangChainCallbackHandler;
//# sourceMappingURL=index.cjs.map