Skip to content

Commit 1d39475

Browse files
[inference] Merge consecutive same-role messages in OpenAI adapter (elastic#264025)
## Summary - Adds `mergeConsecutiveMessages` to the OpenAI message conversion layer (`messagesToOpenAI`), ensuring the LLM always receives strictly alternating user/assistant messages — matching the behavior already present in the Gemini adapter. - Consecutive user messages have their content normalized to array-of-parts format and combined into a single message. Consecutive assistant messages have their text joined with newlines and `tool_calls` arrays merged. - Tool messages are never merged, since each is tied to a specific `tool_call_id`. - Since `messagesToOpenAI` is shared by the `openai`, `inference`, and `inference_endpoint` adapters, this single change covers all three code paths. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
1 parent c577a19 commit 1d39475

3 files changed

Lines changed: 258 additions & 70 deletions

File tree

x-pack/platform/plugins/shared/inference/server/chat_complete/adapters/openai/to_openai.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,4 +226,138 @@ describe('messagesToOpenAI', () => {
226226
},
227227
]);
228228
});
229+
230+
describe('message merging', () => {
231+
it('merges consecutive user messages into a single message with array content', () => {
232+
const result = messagesToOpenAI({
233+
messages: [
234+
{ role: MessageRole.User, content: 'first' },
235+
{ role: MessageRole.User, content: 'second' },
236+
],
237+
});
238+
239+
expect(result).toEqual([
240+
{
241+
role: 'user',
242+
content: [
243+
{ type: 'text', text: 'first' },
244+
{ type: 'text', text: 'second' },
245+
],
246+
},
247+
]);
248+
});
249+
250+
it('merges consecutive user messages with mixed string and array content', () => {
251+
const result = messagesToOpenAI({
252+
messages: [
253+
{ role: MessageRole.User, content: 'text message' },
254+
{
255+
role: MessageRole.User,
256+
content: [
257+
{ type: 'image', source: { data: 'base64data', mimeType: 'image/png' } },
258+
{ type: 'text', text: 'with image' },
259+
],
260+
},
261+
],
262+
});
263+
264+
expect(result).toEqual([
265+
{
266+
role: 'user',
267+
content: [
268+
{ type: 'text', text: 'text message' },
269+
{ type: 'image_url', image_url: { url: 'base64data' } },
270+
{ type: 'text', text: 'with image' },
271+
],
272+
},
273+
]);
274+
});
275+
276+
it('does not merge non-consecutive same-role messages', () => {
277+
const result = messagesToOpenAI({
278+
messages: [
279+
{ role: MessageRole.User, content: 'first' },
280+
{ role: MessageRole.Assistant, content: 'response' },
281+
{ role: MessageRole.User, content: 'second' },
282+
],
283+
});
284+
285+
expect(result).toHaveLength(3);
286+
expect(result[0]).toEqual({ role: 'user', content: 'first' });
287+
expect(result[1]).toEqual(
288+
expect.objectContaining({ role: 'assistant', content: 'response' })
289+
);
290+
expect(result[2]).toEqual({ role: 'user', content: 'second' });
291+
});
292+
293+
it('does not merge consecutive tool messages', () => {
294+
const result = messagesToOpenAI({
295+
messages: [
296+
{ role: MessageRole.Tool, name: 'tool', toolCallId: 'call-1', response: { result: 'a' } },
297+
{ role: MessageRole.Tool, name: 'tool', toolCallId: 'call-2', response: { result: 'b' } },
298+
],
299+
});
300+
301+
expect(result).toHaveLength(2);
302+
expect(result[0]).toEqual({
303+
role: 'tool',
304+
content: '{"result":"a"}',
305+
tool_call_id: 'call-1',
306+
});
307+
expect(result[1]).toEqual({
308+
role: 'tool',
309+
content: '{"result":"b"}',
310+
tool_call_id: 'call-2',
311+
});
312+
});
313+
314+
it('merges consecutive assistant messages and combines tool_calls', () => {
315+
const result = messagesToOpenAI({
316+
messages: [
317+
{
318+
role: MessageRole.Assistant,
319+
content: 'thinking...',
320+
toolCalls: [
321+
{ toolCallId: 'call-1', function: { name: 'tool_a', arguments: { x: 1 } } },
322+
],
323+
},
324+
{
325+
role: MessageRole.Assistant,
326+
content: 'more thoughts',
327+
toolCalls: [
328+
{ toolCallId: 'call-2', function: { name: 'tool_b', arguments: { y: 2 } } },
329+
],
330+
},
331+
],
332+
});
333+
334+
expect(result).toHaveLength(1);
335+
expect(result[0]).toEqual({
336+
role: 'assistant',
337+
content: 'thinking...\nmore thoughts',
338+
tool_calls: [
339+
expect.objectContaining({
340+
id: 'call-1',
341+
function: expect.objectContaining({ name: 'tool_a' }),
342+
}),
343+
expect.objectContaining({
344+
id: 'call-2',
345+
function: expect.objectContaining({ name: 'tool_b' }),
346+
}),
347+
],
348+
});
349+
});
350+
351+
it('does not merge user messages separated by an empty assistant message', () => {
352+
const result = messagesToOpenAI({
353+
messages: [
354+
{ role: MessageRole.User, content: 'first' },
355+
{ role: MessageRole.Assistant, content: '', toolCalls: [] },
356+
{ role: MessageRole.User, content: 'second' },
357+
],
358+
});
359+
360+
expect(result).toHaveLength(3);
361+
});
362+
});
229363
});

x-pack/platform/plugins/shared/inference/server/chat_complete/adapters/openai/to_openai.ts

Lines changed: 111 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -129,63 +129,117 @@ export function messagesToOpenAI({
129129
? { role: 'system', content: system }
130130
: undefined;
131131

132-
return [
133-
...(systemMessage ? [systemMessage] : []),
134-
...messages.map((message): ChatCompletionMessageParam => {
135-
const role = message.role;
136-
137-
switch (role) {
138-
case MessageRole.Assistant:
139-
const assistantMessage: ChatCompletionAssistantMessageParam = {
140-
role: 'assistant',
141-
content: message.content ?? '',
142-
tool_calls: message.toolCalls?.map((toolCall) => {
143-
return {
144-
function: {
145-
name: toolCall.function.name,
146-
arguments:
147-
'arguments' in toolCall.function
148-
? JSON.stringify(toolCall.function.arguments)
149-
: '{}',
150-
},
151-
id: toolCall.toolCallId,
152-
type: 'function',
153-
};
154-
}),
155-
};
156-
return assistantMessage;
157-
158-
case MessageRole.User:
159-
const userMessage: ChatCompletionUserMessageParam = {
160-
role: 'user',
161-
content:
162-
typeof message.content === 'string'
163-
? message.content
164-
: message.content.map((contentPart) => {
165-
if (contentPart.type === 'image') {
166-
return {
167-
type: 'image_url',
168-
image_url: {
169-
url: contentPart.source.data,
170-
},
171-
} satisfies ChatCompletionContentPartImage;
172-
}
132+
const converted = messages.map((message): ChatCompletionMessageParam => {
133+
const role = message.role;
134+
135+
switch (role) {
136+
case MessageRole.Assistant:
137+
const assistantMessage: ChatCompletionAssistantMessageParam = {
138+
role: 'assistant',
139+
content: message.content ?? '',
140+
tool_calls: message.toolCalls?.map((toolCall) => {
141+
return {
142+
function: {
143+
name: toolCall.function.name,
144+
arguments:
145+
'arguments' in toolCall.function
146+
? JSON.stringify(toolCall.function.arguments)
147+
: '{}',
148+
},
149+
id: toolCall.toolCallId,
150+
type: 'function',
151+
};
152+
}),
153+
};
154+
return assistantMessage;
155+
156+
case MessageRole.User:
157+
const userMessage: ChatCompletionUserMessageParam = {
158+
role: 'user',
159+
content:
160+
typeof message.content === 'string'
161+
? message.content
162+
: message.content.map((contentPart) => {
163+
if (contentPart.type === 'image') {
173164
return {
174-
text: contentPart.text,
175-
type: 'text',
176-
} satisfies ChatCompletionContentPartText;
177-
}),
178-
};
179-
return userMessage;
180-
181-
case MessageRole.Tool:
182-
const toolMessage: ChatCompletionToolMessageParam = {
183-
role: 'tool',
184-
content: JSON.stringify(message.response),
185-
tool_call_id: message.toolCallId,
186-
};
187-
return toolMessage;
165+
type: 'image_url',
166+
image_url: {
167+
url: contentPart.source.data,
168+
},
169+
} satisfies ChatCompletionContentPartImage;
170+
}
171+
return {
172+
text: contentPart.text,
173+
type: 'text',
174+
} satisfies ChatCompletionContentPartText;
175+
}),
176+
};
177+
return userMessage;
178+
179+
case MessageRole.Tool:
180+
const toolMessage: ChatCompletionToolMessageParam = {
181+
role: 'tool',
182+
content: JSON.stringify(message.response),
183+
tool_call_id: message.toolCallId,
184+
};
185+
return toolMessage;
186+
}
187+
});
188+
189+
return [...(systemMessage ? [systemMessage] : []), ...mergeConsecutiveMessages(converted)];
190+
}
191+
192+
/**
193+
* Merges consecutive messages with the same role into a single message.
194+
* - User messages: content is normalized to array format and parts are concatenated.
195+
* - Assistant messages: content strings are joined with newline, tool_calls are combined.
196+
* - Tool messages are never merged (each is tied to a specific tool_call_id).
197+
* - System messages are not affected (prepended separately).
198+
*/
199+
function mergeConsecutiveMessages(
200+
messages: ChatCompletionMessageParam[]
201+
): ChatCompletionMessageParam[] {
202+
return messages.reduce<ChatCompletionMessageParam[]>((output, message) => {
203+
const previous = output.length ? output[output.length - 1] : undefined;
204+
205+
if (
206+
previous &&
207+
previous.role === message.role &&
208+
message.role !== 'tool' &&
209+
message.role !== 'system'
210+
) {
211+
if (message.role === 'user' && previous.role === 'user') {
212+
const previousParts = normalizeUserContent(previous.content);
213+
const currentParts = normalizeUserContent(message.content);
214+
previous.content = [...previousParts, ...currentParts];
215+
} else if (message.role === 'assistant' && previous.role === 'assistant') {
216+
const prevContent = (previous as ChatCompletionAssistantMessageParam).content ?? '';
217+
const curContent = (message as ChatCompletionAssistantMessageParam).content ?? '';
218+
(previous as ChatCompletionAssistantMessageParam).content = [prevContent, curContent]
219+
.filter(Boolean)
220+
.join('\n');
221+
const prevCalls = (previous as ChatCompletionAssistantMessageParam).tool_calls;
222+
const curCalls = (message as ChatCompletionAssistantMessageParam).tool_calls;
223+
if (curCalls?.length) {
224+
(previous as ChatCompletionAssistantMessageParam).tool_calls = [
225+
...(prevCalls ?? []),
226+
...curCalls,
227+
];
228+
}
188229
}
189-
}),
190-
];
230+
} else {
231+
output.push(message);
232+
}
233+
234+
return output;
235+
}, []);
236+
}
237+
238+
function normalizeUserContent(
239+
content: ChatCompletionUserMessageParam['content']
240+
): Array<ChatCompletionContentPartText | ChatCompletionContentPartImage> {
241+
if (typeof content === 'string') {
242+
return [{ type: 'text', text: content }];
243+
}
244+
return content as Array<ChatCompletionContentPartText | ChatCompletionContentPartImage>;
191245
}

x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/ai_assistant/anonymization/anonymization.spec.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -108,19 +108,19 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
108108

109109
it('does not send detected entities to the LLM via chat/complete', async () => {
110110
const userMsgsReq = simulator.requestBody.messages.filter((m: any) => m.role === 'user');
111-
expect(userMsgsReq).to.have.length(2);
112-
// First message
113-
const firstMsgReq = userMsgsReq[0].content;
114-
expect(firstMsgReq).to.not.contain('claudia@example.com');
115-
expect(
116-
typeof firstMsgReq === 'string' && (firstMsgReq.match(/[0-9a-f]{40}/g) || []).length
117-
).to.be(1);
118-
// Second message
119-
const secMsgReq = userMsgsReq[1].content;
120-
expect(secMsgReq).to.not.contain('http://claudia.is');
121-
expect(
122-
typeof secMsgReq === 'string' && (secMsgReq.match(/[0-9a-f]{40}/g) || []).length
123-
).to.be(1);
111+
// Consecutive user messages are merged into a single message with array content
112+
expect(userMsgsReq).to.have.length(1);
113+
const contentParts = userMsgsReq[0].content!;
114+
expect(contentParts).to.be.an('array');
115+
expect(contentParts).to.have.length(2);
116+
// First content part (email anonymized)
117+
const firstPart = (contentParts[0] as { text: string }).text;
118+
expect(firstPart).to.not.contain('claudia@example.com');
119+
expect((firstPart.match(/[0-9a-f]{40}/g) || []).length).to.be(1);
120+
// Second content part (URL anonymized)
121+
const secPart = (contentParts[1] as { text: string }).text;
122+
expect(secPart).to.not.contain('http://claudia.is');
123+
expect((secPart.match(/[0-9a-f]{40}/g) || []).length).to.be(1);
124124
});
125125

126126
it('stores deanonymized messages and deanonymizations in Elasticsearch', async () => {

0 commit comments

Comments
 (0)