@posthog/ai 7.17.4 → 7.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -9,228 +9,160 @@ import { GoogleGenAI } from '@google/genai';
9
9
  const isString = value => {
10
10
  return typeof value === 'string';
11
11
  };
12
- const isObject = value => {
13
- return value !== null && typeof value === 'object' && !Array.isArray(value);
14
- };
15
12
 
16
- const REDACTED_IMAGE_PLACEHOLDER = '[base64 image redacted]';
17
- // ============================================
18
- // Multimodal Feature Toggle
19
- // ============================================
20
- const isMultimodalEnabled = () => {
21
- const val = process.env._INTERNAL_LLMA_MULTIMODAL || '';
22
- return val.toLowerCase() === 'true' || val === '1' || val.toLowerCase() === 'yes';
23
- };
24
- // ============================================
25
- // Base64 Detection Helpers
26
- // ============================================
27
- const isBase64DataUrl = str => {
28
- return /^data:([^;]+);base64,/.test(str);
29
- };
30
- const isValidUrl = str => {
31
- try {
32
- new URL(str);
33
- return true;
34
- } catch {
35
- // Not an absolute URL, check if it's a relative URL or path
36
- return str.startsWith('/') || str.startsWith('./') || str.startsWith('../');
37
- }
38
- };
39
- const isRawBase64 = str => {
40
- // Skip if it's a valid URL or path
41
- if (isValidUrl(str)) {
42
- return false;
43
- }
44
- // Check if it's a valid base64 string
45
- // Base64 images are typically at least a few hundred chars, but we'll be conservative
46
- return str.length > 20 && /^[A-Za-z0-9+/]+=*$/.test(str);
47
- };
48
- function redactBase64DataUrl(str) {
49
- if (isMultimodalEnabled()) return str;
50
- if (!isString(str)) return str;
51
- // Check for data URL format
52
- if (isBase64DataUrl(str)) {
53
- return REDACTED_IMAGE_PLACEHOLDER;
54
- }
55
- // Check for raw base64 (Vercel sends raw base64 for inline images)
56
- if (isRawBase64(str)) {
57
- return REDACTED_IMAGE_PLACEHOLDER;
58
- }
59
- return str;
60
- }
61
- const processMessages = (messages, transformContent) => {
62
- if (!messages) return messages;
63
- const processContent = content => {
64
- if (typeof content === 'string') return content;
65
- if (!content) return content;
66
- if (Array.isArray(content)) {
67
- return content.map(transformContent);
68
- }
69
- // Handle single object content
70
- return transformContent(content);
71
- };
72
- const processMessage = msg => {
73
- if (!isObject(msg) || !('content' in msg)) return msg;
74
- return {
75
- ...msg,
76
- content: processContent(msg.content)
13
+ const DATA_URL_PREFIX_RE = /^data:([^;,\s]+)(?:;[^;,\s]+)*;base64,/i;
14
+ const BASE64_ALPHABET_RE = /^[A-Za-z0-9+/_=-]+$/;
15
+ class Base64Recognizer {
16
+ recognize(value, minLength) {
17
+ const dataUrl = DATA_URL_PREFIX_RE.exec(value);
18
+ if (dataUrl) return {
19
+ kind: 'data-url',
20
+ mediaType: dataUrl[1]
77
21
  };
78
- };
79
- // Handle both arrays and single messages
80
- if (Array.isArray(messages)) {
81
- return messages.map(processMessage);
82
- }
83
- return processMessage(messages);
84
- };
85
- // ============================================
86
- // Provider-Specific Image Sanitizers
87
- // ============================================
88
- const sanitizeOpenAIImage = item => {
89
- if (!isObject(item)) return item;
90
- // Handle image_url format
91
- if (item.type === 'image_url' && 'image_url' in item && isObject(item.image_url) && 'url' in item.image_url) {
92
- return {
93
- ...item,
94
- image_url: {
95
- ...item.image_url,
96
- url: redactBase64DataUrl(item.image_url.url)
97
- }
98
- };
99
- }
100
- // Handle video_url format
101
- if (item.type === 'video_url' && 'video_url' in item && isObject(item.video_url) && 'url' in item.video_url) {
102
- return {
103
- ...item,
104
- video_url: {
105
- ...item.video_url,
106
- url: redactBase64DataUrl(item.video_url.url)
107
- }
108
- };
109
- }
110
- // Handle audio format
111
- if (item.type === 'audio' && 'data' in item) {
112
- if (isMultimodalEnabled()) return item;
113
- return {
114
- ...item,
115
- data: REDACTED_IMAGE_PLACEHOLDER
22
+ if (value.length < minLength) return {
23
+ kind: 'none'
116
24
  };
25
+ const confidencePrefix = value.slice(0, minLength);
26
+ if (BASE64_ALPHABET_RE.test(confidencePrefix)) {
27
+ return {
28
+ kind: 'raw'
29
+ };
30
+ } else {
31
+ return {
32
+ kind: 'none'
33
+ };
34
+ }
117
35
  }
118
- return item;
119
- };
120
- const sanitizeOpenAIResponseImage = item => {
121
- if (!isObject(item)) return item;
122
- // Handle input_image format
123
- if (item.type === 'input_image' && 'image_url' in item) {
124
- return {
125
- ...item,
126
- image_url: redactBase64DataUrl(item.image_url)
127
- };
36
+ }
37
+
38
+ const MIME_HINT_KEYS = ['mediaType', 'media_type', 'mimeType', 'mime_type'];
39
+ 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']);
40
+ const STRONG_CONTEXT_TYPES = new Set(['image', 'image_url', 'input_image', 'audio', 'input_audio', 'video', 'video_url', 'file', 'input_file', 'document', 'media', 'file-data']);
41
+ const FILE_FAMILY_TYPES = new Set(['file', 'input_file', 'document', 'media', 'file-data']);
42
+ const KNOWN_AUDIO_FORMATS = new Set(['wav', 'mp3', 'ogg', 'flac', 'm4a', 'aac', 'webm']);
43
+ class MediaTypeContext {
44
+ constructor(parent, key) {
45
+ this.parent = parent;
46
+ this.key = key;
47
+ }
48
+ inferMediaType() {
49
+ return this.inferFromSiblingMime() ?? this.inferFromSiblingFormat() ?? this.inferFromParentType() ?? this.inferFromKey();
50
+ }
51
+ inferFromSiblingMime() {
52
+ if (!this.parent) return undefined;
53
+ for (const hint of MIME_HINT_KEYS) {
54
+ const v = this.parent[hint];
55
+ if (typeof v === 'string') return v;
56
+ }
57
+ return undefined;
128
58
  }
129
- return item;
130
- };
131
- const sanitizeAnthropicImage = item => {
132
- if (isMultimodalEnabled()) return item;
133
- if (!isObject(item)) return item;
134
- // Handle Anthropic's image and document formats (same structure, different type field)
135
- if ((item.type === 'image' || item.type === 'document') && 'source' in item && isObject(item.source) && item.source.type === 'base64' && 'data' in item.source) {
136
- return {
137
- ...item,
138
- source: {
139
- ...item.source,
140
- data: REDACTED_IMAGE_PLACEHOLDER
141
- }
142
- };
59
+ inferFromSiblingFormat() {
60
+ if (!this.parent) return undefined;
61
+ const fmt = this.parent.format;
62
+ if (typeof fmt === 'string' && KNOWN_AUDIO_FORMATS.has(fmt.toLowerCase())) {
63
+ return `audio/${fmt.toLowerCase()}`;
64
+ }
65
+ return undefined;
143
66
  }
144
- return item;
145
- };
146
- const sanitizeGeminiPart = part => {
147
- if (isMultimodalEnabled()) return part;
148
- if (!isObject(part)) return part;
149
- // Handle Gemini's inline data format (images, audio, PDFs all use inlineData)
150
- if ('inlineData' in part && isObject(part.inlineData) && 'data' in part.inlineData) {
151
- return {
152
- ...part,
153
- inlineData: {
154
- ...part.inlineData,
155
- data: REDACTED_IMAGE_PLACEHOLDER
156
- }
157
- };
67
+ inferFromParentType() {
68
+ if (!this.parent) return undefined;
69
+ const t = this.parent.type;
70
+ if (typeof t !== 'string') return undefined;
71
+ if (t === 'image' || t === 'image_url' || t === 'input_image') return 'image';
72
+ if (t === 'audio' || t === 'input_audio') return 'audio';
73
+ if (t === 'video' || t === 'video_url') return 'video';
74
+ if (FILE_FAMILY_TYPES.has(t)) return 'application/octet-stream';
75
+ return undefined;
158
76
  }
159
- return part;
160
- };
161
- const processGeminiItem = item => {
162
- if (!isObject(item)) return item;
163
- // If it has parts, process them
164
- if ('parts' in item && item.parts) {
165
- const parts = Array.isArray(item.parts) ? item.parts.map(sanitizeGeminiPart) : sanitizeGeminiPart(item.parts);
166
- return {
167
- ...item,
168
- parts
169
- };
77
+ inferFromKey() {
78
+ if (!this.key) return undefined;
79
+ const key = this.key.toLowerCase();
80
+ if (key.includes('audio')) return 'audio';
81
+ if (key.includes('video')) return 'video';
82
+ if (key.includes('image')) return 'image';
83
+ if (key.includes('file') || key.includes('document')) return 'application/octet-stream';
84
+ return undefined;
170
85
  }
171
- return item;
172
- };
173
- const sanitizeLangChainImage = item => {
174
- if (!isObject(item)) return item;
175
- // OpenAI style
176
- if (item.type === 'image_url' && 'image_url' in item && isObject(item.image_url) && 'url' in item.image_url) {
177
- return {
178
- ...item,
179
- image_url: {
180
- ...item.image_url,
181
- url: redactBase64DataUrl(item.image_url.url)
86
+ signalsBinary() {
87
+ if (this.parent) {
88
+ for (const hint of MIME_HINT_KEYS) {
89
+ if (typeof this.parent[hint] === 'string') return true;
182
90
  }
183
- };
91
+ const fmt = this.parent.format;
92
+ if (typeof fmt === 'string' && KNOWN_AUDIO_FORMATS.has(fmt.toLowerCase())) return true;
93
+ const t = this.parent.type;
94
+ if (typeof t === 'string' && STRONG_CONTEXT_TYPES.has(t)) return true;
95
+ }
96
+ if (this.key && STRONG_CONTEXT_KEYS.has(this.key)) return true;
97
+ return false;
184
98
  }
185
- // Direct image with data field
186
- if (item.type === 'image' && 'data' in item) {
187
- return {
188
- ...item,
189
- data: redactBase64DataUrl(item.data)
190
- };
99
+ }
100
+ MediaTypeContext.EMPTY = new MediaTypeContext(undefined, undefined);
101
+
102
+ const STRONG_CONTEXT_MIN_LENGTH = 64;
103
+ const WEAK_CONTEXT_MIN_LENGTH = 1024;
104
+ class BinaryContentRedactor {
105
+ constructor(recognizer = new Base64Recognizer()) {
106
+ this.recognizer = recognizer;
107
+ this.visited = new WeakSet();
108
+ }
109
+ redact(value) {
110
+ if (this.isMultimodalEnabled()) return value;
111
+ this.visited = new WeakSet();
112
+ return this.walk(value, MediaTypeContext.EMPTY);
113
+ }
114
+ walk(value, ctx) {
115
+ if (value === null || value === undefined) return value;
116
+ if (typeof value === 'string') return this.redactString(value, ctx);
117
+ if (typeof value !== 'object') return value;
118
+ // Buffer extends Uint8Array, so this branch catches both.
119
+ if (typeof Uint8Array !== 'undefined' && value instanceof Uint8Array) {
120
+ return this.placeholderFor(ctx.inferMediaType());
121
+ }
122
+ if (this.visited.has(value)) return null;
123
+ this.visited.add(value);
124
+ if (Array.isArray(value)) {
125
+ return value.map(item => this.walk(item, ctx));
126
+ }
127
+ const obj = value;
128
+ const out = {};
129
+ for (const k of Object.keys(obj)) {
130
+ out[k] = this.walk(obj[k], new MediaTypeContext(obj, k));
131
+ }
132
+ return out;
133
+ }
134
+ redactString(value, ctx) {
135
+ const minLength = ctx.signalsBinary() ? STRONG_CONTEXT_MIN_LENGTH : WEAK_CONTEXT_MIN_LENGTH;
136
+ const recognition = this.recognizer.recognize(value, minLength);
137
+ switch (recognition.kind) {
138
+ case 'data-url':
139
+ return this.placeholderFor(recognition.mediaType);
140
+ case 'raw':
141
+ return this.placeholderFor(ctx.inferMediaType());
142
+ case 'none':
143
+ return value;
144
+ }
191
145
  }
192
- // Anthropic style
193
- if (item.type === 'image' && 'source' in item && isObject(item.source) && 'data' in item.source) {
194
- if (isMultimodalEnabled()) return item;
195
- return {
196
- ...item,
197
- source: {
198
- ...item.source,
199
- data: redactBase64DataUrl(item.source.data)
200
- }
201
- };
146
+ placeholderFor(mediaType) {
147
+ if (!mediaType) return '[base64 redacted]';
148
+ if (mediaType === 'application/octet-stream') return '[base64 file redacted]';
149
+ return `[base64 ${mediaType} redacted]`;
202
150
  }
203
- // Google style
204
- if (item.type === 'media' && 'data' in item) {
205
- return {
206
- ...item,
207
- data: redactBase64DataUrl(item.data)
208
- };
151
+ isMultimodalEnabled() {
152
+ const val = process.env._INTERNAL_LLMA_MULTIMODAL || '';
153
+ return val.toLowerCase() === 'true' || val === '1' || val.toLowerCase() === 'yes';
209
154
  }
210
- return item;
211
- };
212
- // Export individual sanitizers for tree-shaking
213
- const sanitizeOpenAI = data => {
214
- return processMessages(data, sanitizeOpenAIImage);
215
- };
216
- const sanitizeOpenAIResponse = data => {
217
- return processMessages(data, sanitizeOpenAIResponseImage);
218
- };
219
- const sanitizeAnthropic = data => {
220
- return processMessages(data, sanitizeAnthropicImage);
221
- };
222
- const sanitizeGemini = data => {
223
- // Gemini has a different structure with 'parts' directly on items instead of 'content'
224
- // So we need custom processing instead of using processMessages
225
- if (!data) return data;
226
- if (Array.isArray(data)) {
227
- return data.map(processGeminiItem);
228
- }
229
- return processGeminiItem(data);
230
- };
231
- const sanitizeLangChain = data => {
232
- return processMessages(data, sanitizeLangChainImage);
233
- };
155
+ }
156
+
157
+ const redactor = new BinaryContentRedactor();
158
+ function redactBase64DataUrl(str) {
159
+ return redactor.redact(str);
160
+ }
161
+ const sanitizeOpenAI = data => redactor.redact(data);
162
+ const sanitizeOpenAIResponse = data => redactor.redact(data);
163
+ const sanitizeAnthropic = data => redactor.redact(data);
164
+ const sanitizeGemini = data => redactor.redact(data);
165
+ const sanitizeLangChain = data => redactor.redact(data);
234
166
 
235
167
  const TOKEN_PROPERTY_KEYS = new Set(['$ai_input_tokens', '$ai_output_tokens', '$ai_cache_read_input_tokens', '$ai_cache_creation_input_tokens', '$ai_total_tokens', '$ai_reasoning_tokens']);
236
168
  function getTokensSource(posthogProperties) {
@@ -777,7 +709,7 @@ function formatOpenAIResponsesInput(input, instructions) {
777
709
  return messages;
778
710
  }
779
711
 
780
- var version = "7.17.4";
712
+ var version = "7.18.0";
781
713
 
782
714
  /**
783
715
  * Capture an `$ai_generation` (or `$ai_embedding`) event to PostHog.