@@ -14,15 +14,20 @@ type ContentMessage = {
1414 content : string ;
1515} ;
1616
17+ /**
18+ * One block inside OpenAI / Anthropic `content: [...]` arrays (text, image_url, etc.).
19+ */
20+ type ContentArrayBlock = {
21+ [ key : string ] : unknown ;
22+ type : string ;
23+ } ;
24+
1725/**
1826 * Message format used by OpenAI and Anthropic APIs for media.
1927 */
2028type ContentArrayMessage = {
2129 [ key : string ] : unknown ;
22- content : {
23- [ key : string ] : unknown ;
24- type : string ;
25- } [ ] ;
30+ content : ContentArrayBlock [ ] ;
2631} ;
2732
2833/**
@@ -47,6 +52,11 @@ type MediaPart = {
4752 content : string ;
4853} ;
4954
55+ /**
56+ * One element of an array-based message: OpenAI/Anthropic `content[]` or Google `parts`.
57+ */
58+ type ArrayMessageItem = TextPart | MediaPart | ContentArrayBlock ;
59+
5060/**
5161 * Calculate the UTF-8 byte length of a string.
5262 */
@@ -95,31 +105,33 @@ function truncateTextByBytes(text: string, maxBytes: number): string {
95105}
96106
97107/**
98- * Extract text content from a Google GenAI message part .
99- * Parts are either plain strings or objects with a text property.
108+ * Extract text content from a message item .
109+ * Handles plain strings and objects with a text property.
100110 *
101111 * @returns The text content
102112 */
103- function getPartText ( part : TextPart | MediaPart ) : string {
104- if ( typeof part === 'string' ) {
105- return part ;
113+ function getItemText ( item : ArrayMessageItem ) : string {
114+ if ( typeof item === 'string' ) {
115+ return item ;
116+ }
117+ if ( 'text' in item && typeof item . text === 'string' ) {
118+ return item . text ;
106119 }
107- if ( 'text' in part ) return part . text ;
108120 return '' ;
109121}
110122
111123/**
112- * Create a new part with updated text content while preserving the original structure.
124+ * Create a new item with updated text content while preserving the original structure.
113125 *
114- * @param part - Original part (string or object)
126+ * @param item - Original item (string or object)
115127 * @param text - New text content
116- * @returns New part with updated text
128+ * @returns New item with updated text
117129 */
118- function withPartText ( part : TextPart | MediaPart , text : string ) : TextPart {
119- if ( typeof part === 'string' ) {
130+ function withItemText ( item : ArrayMessageItem , text : string ) : ArrayMessageItem {
131+ if ( typeof item === 'string' ) {
120132 return text ;
121133 }
122- return { ...part , text } ;
134+ return { ...item , text } ;
123135}
124136
125137/**
@@ -176,56 +188,78 @@ function truncateContentMessage(message: ContentMessage, maxBytes: number): unkn
176188}
177189
178190/**
179- * Truncate a message with `parts: [...]` format (Google GenAI).
180- * Keeps as many complete parts as possible, only truncating the first part if needed.
191+ * Extracts the array items and their key from an array-based message.
192+ * Returns `null` key if neither `parts` nor `content` is a valid array.
193+ */
194+ function getArrayItems ( message : PartsMessage | ContentArrayMessage ) : {
195+ key : 'parts' | 'content' | null ;
196+ items : ArrayMessageItem [ ] ;
197+ } {
198+ if ( 'parts' in message && Array . isArray ( message . parts ) ) {
199+ return { key : 'parts' , items : message . parts } ;
200+ }
201+ if ( 'content' in message && Array . isArray ( message . content ) ) {
202+ return { key : 'content' , items : message . content } ;
203+ }
204+ return { key : null , items : [ ] } ;
205+ }
206+
207+ /**
208+ * Truncate a message with an array-based format.
209+ * Handles both `parts: [...]` (Google GenAI) and `content: [...]` (OpenAI/Anthropic multimodal).
210+ * Keeps as many complete items as possible, only truncating the first item if needed.
181211 *
182- * @param message - Message with parts array
212+ * @param message - Message with parts or content array
183213 * @param maxBytes - Maximum byte limit
184214 * @returns Array with truncated message, or empty array if it doesn't fit
185215 */
186- function truncatePartsMessage ( message : PartsMessage , maxBytes : number ) : unknown [ ] {
187- const { parts } = message ;
216+ function truncateArrayMessage ( message : PartsMessage | ContentArrayMessage , maxBytes : number ) : unknown [ ] {
217+ const { key , items } = getArrayItems ( message ) ;
188218
189- // Calculate overhead by creating empty text parts
190- const emptyParts = parts . map ( part => withPartText ( part , '' ) ) ;
191- const overhead = jsonBytes ( { ...message , parts : emptyParts } ) ;
219+ if ( key === null || items . length === 0 ) {
220+ return [ ] ;
221+ }
222+
223+ // Calculate overhead by creating empty text items
224+ const emptyItems = items . map ( item => withItemText ( item , '' ) ) ;
225+ const overhead = jsonBytes ( { ...message , [ key ] : emptyItems } ) ;
192226 let remainingBytes = maxBytes - overhead ;
193227
194228 if ( remainingBytes <= 0 ) {
195229 return [ ] ;
196230 }
197231
198- // Include parts until we run out of space
199- const includedParts : ( TextPart | MediaPart ) [ ] = [ ] ;
232+ // Include items until we run out of space
233+ const includedItems : ArrayMessageItem [ ] = [ ] ;
200234
201- for ( const part of parts ) {
202- const text = getPartText ( part ) ;
235+ for ( const item of items ) {
236+ const text = getItemText ( item ) ;
203237 const textSize = utf8Bytes ( text ) ;
204238
205239 if ( textSize <= remainingBytes ) {
206- // Part fits: include it as-is
207- includedParts . push ( part ) ;
240+ // Item fits: include it as-is
241+ includedItems . push ( item ) ;
208242 remainingBytes -= textSize ;
209- } else if ( includedParts . length === 0 ) {
210- // First part doesn't fit: truncate it
243+ } else if ( includedItems . length === 0 ) {
244+ // First item doesn't fit: truncate it
211245 const truncated = truncateTextByBytes ( text , remainingBytes ) ;
212246 if ( truncated ) {
213- includedParts . push ( withPartText ( part , truncated ) ) ;
247+ includedItems . push ( withItemText ( item , truncated ) ) ;
214248 }
215249 break ;
216250 } else {
217- // Subsequent part doesn't fit: stop here
251+ // Subsequent item doesn't fit: stop here
218252 break ;
219253 }
220254 }
221255
222256 /* c8 ignore start
223257 * for type safety only, algorithm guarantees SOME text included */
224- if ( includedParts . length <= 0 ) {
258+ if ( includedItems . length <= 0 ) {
225259 return [ ] ;
226260 } else {
227261 /* c8 ignore stop */
228- return [ { ...message , parts : includedParts } ] ;
262+ return [ { ...message , [ key ] : includedItems } ] ;
229263 }
230264}
231265
@@ -258,13 +292,8 @@ function truncateSingleMessage(message: unknown, maxBytes: number): unknown[] {
258292 return truncateContentMessage ( message , maxBytes ) ;
259293 }
260294
261- if ( isContentArrayMessage ( message ) ) {
262- // Content array messages are returned as-is without truncation
263- return [ message ] ;
264- }
265-
266- if ( isPartsMessage ( message ) ) {
267- return truncatePartsMessage ( message , maxBytes ) ;
295+ if ( isContentArrayMessage ( message ) || isPartsMessage ( message ) ) {
296+ return truncateArrayMessage ( message , maxBytes ) ;
268297 }
269298
270299 // Unknown message format: cannot truncate safely
0 commit comments