Skip to content

Commit 51e2cee

Browse files
authored
fix(core): Truncate content array format in Vercel (#19911)
Add truncation for content array messages i.e. messages that have a `content` key, where `content` is an array of objects e.g. `{"type": "text", "text": "some string"}`. Previously these were returned as is bypassing the truncation logic. This PR makes sure these messages get truncated as well. We already handled parts array messages, which have essentially the same format but use a `parts` key. So I basically just generalized the `truncatePartsMessage` to also handle the content array format. Note: After switching to the Span V2 protocol we will get rid of truncation in the SDK altogether, but for now we should make sure to properly truncate all formats. Closes #19919 (added automatically)
1 parent 66b48de commit 51e2cee

File tree

2 files changed

+133
-43
lines changed

2 files changed

+133
-43
lines changed

packages/core/src/tracing/ai/messageTruncation.ts

Lines changed: 72 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
2028
type 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

packages/core/test/lib/tracing/ai-message-truncation.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,5 +547,66 @@ describe('message truncation utilities', () => {
547547
},
548548
]);
549549
});
550+
551+
it('truncates content array message when first text item does not fit', () => {
552+
const messages = [
553+
{
554+
role: 'user',
555+
content: [{ type: 'text', text: `2 ${humongous}` }],
556+
},
557+
];
558+
const result = truncateGenAiMessages(messages);
559+
const truncLen =
560+
20_000 -
561+
2 -
562+
JSON.stringify({
563+
role: 'user',
564+
content: [{ type: 'text', text: '' }],
565+
}).length;
566+
expect(result).toStrictEqual([
567+
{
568+
role: 'user',
569+
content: [{ type: 'text', text: `2 ${humongous}`.substring(0, truncLen) }],
570+
},
571+
]);
572+
});
573+
574+
it('drops subsequent content array items that do not fit', () => {
575+
const messages = [
576+
{
577+
role: 'assistant',
578+
content: [
579+
{ type: 'text', text: `1 ${big}` },
580+
{ type: 'image_url', url: 'https://example.com/img.png' },
581+
{ type: 'text', text: `2 ${big}` },
582+
{ type: 'text', text: `3 ${big}` },
583+
{ type: 'text', text: `4 ${giant}` },
584+
{ type: 'text', text: `5 ${giant}` },
585+
],
586+
},
587+
];
588+
const result = truncateGenAiMessages(messages);
589+
expect(result).toStrictEqual([
590+
{
591+
role: 'assistant',
592+
content: [
593+
{ type: 'text', text: `1 ${big}` },
594+
{ type: 'image_url', url: 'https://example.com/img.png' },
595+
{ type: 'text', text: `2 ${big}` },
596+
{ type: 'text', text: `3 ${big}` },
597+
],
598+
},
599+
]);
600+
});
601+
602+
it('drops content array message if overhead is too large', () => {
603+
const messages = [
604+
{
605+
some_other_field: humongous,
606+
content: [{ type: 'text', text: 'hello' }],
607+
},
608+
];
609+
expect(truncateGenAiMessages(messages)).toStrictEqual([]);
610+
});
550611
});
551612
});

0 commit comments

Comments
 (0)