@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.
@@ -7,100 +7,157 @@ import { uuidv7 } from '@posthog/core';
7
7
  const isString = value => {
8
8
  return typeof value === 'string';
9
9
  };
10
- const isObject = value => {
11
- return value !== null && typeof value === 'object' && !Array.isArray(value);
12
- };
13
-
14
- const REDACTED_IMAGE_PLACEHOLDER = '[base64 image redacted]';
15
-
16
- // ============================================
17
- // Multimodal Feature Toggle
18
- // ============================================
19
-
20
- const isMultimodalEnabled = () => {
21
- const val = process.env._INTERNAL_LLMA_MULTIMODAL || '';
22
- return val.toLowerCase() === 'true' || val === '1' || val.toLowerCase() === 'yes';
23
- };
24
10
 
25
- // ============================================
26
- // Base64 Detection Helpers
27
- // ============================================
11
+ const DATA_URL_PREFIX_RE = /^data:([^;,\s]+)(?:;[^;,\s]+)*;base64,/i;
12
+ const BASE64_ALPHABET_RE = /^[A-Za-z0-9+/_=-]+$/;
13
+ class Base64Recognizer {
14
+ recognize(value, minLength) {
15
+ const dataUrl = DATA_URL_PREFIX_RE.exec(value);
16
+ if (dataUrl) return {
17
+ kind: 'data-url',
18
+ mediaType: dataUrl[1]
19
+ };
20
+ if (value.length < minLength) return {
21
+ kind: 'none'
22
+ };
23
+ const confidencePrefix = value.slice(0, minLength);
24
+ if (BASE64_ALPHABET_RE.test(confidencePrefix)) {
25
+ return {
26
+ kind: 'raw'
27
+ };
28
+ } else {
29
+ return {
30
+ kind: 'none'
31
+ };
32
+ }
33
+ }
34
+ }
28
35
 
29
- const isBase64DataUrl = str => {
30
- return /^data:([^;]+);base64,/.test(str);
31
- };
32
- const isValidUrl = str => {
33
- try {
34
- new URL(str);
35
- return true;
36
- } catch {
37
- // Not an absolute URL, check if it's a relative URL or path
38
- return str.startsWith('/') || str.startsWith('./') || str.startsWith('../');
36
+ const MIME_HINT_KEYS = ['mediaType', 'media_type', 'mimeType', 'mime_type'];
37
+ 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']);
38
+ const STRONG_CONTEXT_TYPES = new Set(['image', 'image_url', 'input_image', 'audio', 'input_audio', 'video', 'video_url', 'file', 'input_file', 'document', 'media', 'file-data']);
39
+ const FILE_FAMILY_TYPES = new Set(['file', 'input_file', 'document', 'media', 'file-data']);
40
+ const KNOWN_AUDIO_FORMATS = new Set(['wav', 'mp3', 'ogg', 'flac', 'm4a', 'aac', 'webm']);
41
+ class MediaTypeContext {
42
+ static EMPTY = new MediaTypeContext(undefined, undefined);
43
+ constructor(parent, key) {
44
+ this.parent = parent;
45
+ this.key = key;
46
+ }
47
+ inferMediaType() {
48
+ return this.inferFromSiblingMime() ?? this.inferFromSiblingFormat() ?? this.inferFromParentType() ?? this.inferFromKey();
49
+ }
50
+ inferFromSiblingMime() {
51
+ if (!this.parent) return undefined;
52
+ for (const hint of MIME_HINT_KEYS) {
53
+ const v = this.parent[hint];
54
+ if (typeof v === 'string') return v;
55
+ }
56
+ return undefined;
39
57
  }
40
- };
41
- const isRawBase64 = str => {
42
- // Skip if it's a valid URL or path
43
- if (isValidUrl(str)) {
58
+ inferFromSiblingFormat() {
59
+ if (!this.parent) return undefined;
60
+ const fmt = this.parent.format;
61
+ if (typeof fmt === 'string' && KNOWN_AUDIO_FORMATS.has(fmt.toLowerCase())) {
62
+ return `audio/${fmt.toLowerCase()}`;
63
+ }
64
+ return undefined;
65
+ }
66
+ inferFromParentType() {
67
+ if (!this.parent) return undefined;
68
+ const t = this.parent.type;
69
+ if (typeof t !== 'string') return undefined;
70
+ if (t === 'image' || t === 'image_url' || t === 'input_image') return 'image';
71
+ if (t === 'audio' || t === 'input_audio') return 'audio';
72
+ if (t === 'video' || t === 'video_url') return 'video';
73
+ if (FILE_FAMILY_TYPES.has(t)) return 'application/octet-stream';
74
+ return undefined;
75
+ }
76
+ inferFromKey() {
77
+ if (!this.key) return undefined;
78
+ const key = this.key.toLowerCase();
79
+ if (key.includes('audio')) return 'audio';
80
+ if (key.includes('video')) return 'video';
81
+ if (key.includes('image')) return 'image';
82
+ if (key.includes('file') || key.includes('document')) return 'application/octet-stream';
83
+ return undefined;
84
+ }
85
+ signalsBinary() {
86
+ if (this.parent) {
87
+ for (const hint of MIME_HINT_KEYS) {
88
+ if (typeof this.parent[hint] === 'string') return true;
89
+ }
90
+ const fmt = this.parent.format;
91
+ if (typeof fmt === 'string' && KNOWN_AUDIO_FORMATS.has(fmt.toLowerCase())) return true;
92
+ const t = this.parent.type;
93
+ if (typeof t === 'string' && STRONG_CONTEXT_TYPES.has(t)) return true;
94
+ }
95
+ if (this.key && STRONG_CONTEXT_KEYS.has(this.key)) return true;
44
96
  return false;
45
97
  }
98
+ }
46
99
 
47
- // Check if it's a valid base64 string
48
- // Base64 images are typically at least a few hundred chars, but we'll be conservative
49
- return str.length > 20 && /^[A-Za-z0-9+/]+=*$/.test(str);
50
- };
51
- function redactBase64DataUrl(str) {
52
- if (isMultimodalEnabled()) return str;
53
- if (!isString(str)) return str;
54
-
55
- // Check for data URL format
56
- if (isBase64DataUrl(str)) {
57
- return REDACTED_IMAGE_PLACEHOLDER;
100
+ const STRONG_CONTEXT_MIN_LENGTH = 64;
101
+ const WEAK_CONTEXT_MIN_LENGTH = 1024;
102
+ class BinaryContentRedactor {
103
+ visited = new WeakSet();
104
+ constructor(recognizer = new Base64Recognizer()) {
105
+ this.recognizer = recognizer;
106
+ }
107
+ redact(value) {
108
+ if (this.isMultimodalEnabled()) return value;
109
+ this.visited = new WeakSet();
110
+ return this.walk(value, MediaTypeContext.EMPTY);
111
+ }
112
+ walk(value, ctx) {
113
+ if (value === null || value === undefined) return value;
114
+ if (typeof value === 'string') return this.redactString(value, ctx);
115
+ if (typeof value !== 'object') return value;
116
+
117
+ // Buffer extends Uint8Array, so this branch catches both.
118
+ if (typeof Uint8Array !== 'undefined' && value instanceof Uint8Array) {
119
+ return this.placeholderFor(ctx.inferMediaType());
120
+ }
121
+ if (this.visited.has(value)) return null;
122
+ this.visited.add(value);
123
+ if (Array.isArray(value)) {
124
+ return value.map(item => this.walk(item, ctx));
125
+ }
126
+ const obj = value;
127
+ const out = {};
128
+ for (const k of Object.keys(obj)) {
129
+ out[k] = this.walk(obj[k], new MediaTypeContext(obj, k));
130
+ }
131
+ return out;
132
+ }
133
+ redactString(value, ctx) {
134
+ const minLength = ctx.signalsBinary() ? STRONG_CONTEXT_MIN_LENGTH : WEAK_CONTEXT_MIN_LENGTH;
135
+ const recognition = this.recognizer.recognize(value, minLength);
136
+ switch (recognition.kind) {
137
+ case 'data-url':
138
+ return this.placeholderFor(recognition.mediaType);
139
+ case 'raw':
140
+ return this.placeholderFor(ctx.inferMediaType());
141
+ case 'none':
142
+ return value;
143
+ }
58
144
  }
59
-
60
- // Check for raw base64 (Vercel sends raw base64 for inline images)
61
- if (isRawBase64(str)) {
62
- return REDACTED_IMAGE_PLACEHOLDER;
145
+ placeholderFor(mediaType) {
146
+ if (!mediaType) return '[base64 redacted]';
147
+ if (mediaType === 'application/octet-stream') return '[base64 file redacted]';
148
+ return `[base64 ${mediaType} redacted]`;
63
149
  }
64
- return str;
65
- }
66
- const sanitizeGeminiPart = part => {
67
- if (isMultimodalEnabled()) return part;
68
- if (!isObject(part)) return part;
69
-
70
- // Handle Gemini's inline data format (images, audio, PDFs all use inlineData)
71
- if ('inlineData' in part && isObject(part.inlineData) && 'data' in part.inlineData) {
72
- return {
73
- ...part,
74
- inlineData: {
75
- ...part.inlineData,
76
- data: REDACTED_IMAGE_PLACEHOLDER
77
- }
78
- };
150
+ isMultimodalEnabled() {
151
+ const val = process.env._INTERNAL_LLMA_MULTIMODAL || '';
152
+ return val.toLowerCase() === 'true' || val === '1' || val.toLowerCase() === 'yes';
79
153
  }
80
- return part;
81
- };
82
- const processGeminiItem = item => {
83
- if (!isObject(item)) return item;
154
+ }
84
155
 
85
- // If it has parts, process them
86
- if ('parts' in item && item.parts) {
87
- const parts = Array.isArray(item.parts) ? item.parts.map(sanitizeGeminiPart) : sanitizeGeminiPart(item.parts);
88
- return {
89
- ...item,
90
- parts
91
- };
92
- }
93
- return item;
94
- };
95
- const sanitizeGemini = data => {
96
- // Gemini has a different structure with 'parts' directly on items instead of 'content'
97
- // So we need custom processing instead of using processMessages
98
- if (!data) return data;
99
- if (Array.isArray(data)) {
100
- return data.map(processGeminiItem);
101
- }
102
- return processGeminiItem(data);
103
- };
156
+ const redactor = new BinaryContentRedactor();
157
+ function redactBase64DataUrl(str) {
158
+ return redactor.redact(str);
159
+ }
160
+ const sanitizeGemini = data => redactor.redact(data);
104
161
 
105
162
  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']);
106
163
  function getTokensSource(posthogProperties) {
@@ -323,7 +380,7 @@ function addDefaults(params) {
323
380
  };
324
381
  }
325
382
 
326
- var version = "7.17.4";
383
+ var version = "7.18.0";
327
384
 
328
385
  /**
329
386
  * Options for `captureAiGeneration`. Mirrors the `$ai_generation` event shape