Skip to content

Commit ecb5339

Browse files
committed
fix: 合并 Langfuse OpenAI tool_calls
1 parent 8f65339 commit ecb5339

2 files changed

Lines changed: 98 additions & 7 deletions

File tree

src/services/langfuse/__tests__/langfuse.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,54 @@ describe('Langfuse integration', () => {
228228
{ role: 'tool', content: 'tool output', tool_call_id: 'call_1' },
229229
])
230230
})
231+
232+
test('merges assistant tool calls from OpenAI-style array content', async () => {
233+
const { convertMessagesToLangfuse } = await import('../convert.js')
234+
const result = convertMessagesToLangfuse([
235+
{
236+
role: 'assistant',
237+
content: [
238+
{
239+
type: 'text',
240+
text: 'calling a tool',
241+
tool_calls: [
242+
{
243+
id: 'call_from_part',
244+
type: 'function',
245+
function: { name: 'part_tool', arguments: '{}' },
246+
},
247+
],
248+
},
249+
],
250+
tool_calls: [
251+
{
252+
id: 'call_from_message',
253+
type: 'function',
254+
function: { name: 'message_tool', arguments: '{"ok":true}' },
255+
},
256+
],
257+
},
258+
])
259+
260+
expect(result).toEqual([
261+
{
262+
role: 'assistant',
263+
content: 'calling a tool',
264+
tool_calls: [
265+
{
266+
id: 'call_from_message',
267+
type: 'function',
268+
function: { name: 'message_tool', arguments: '{"ok":true}' },
269+
},
270+
{
271+
id: 'call_from_part',
272+
type: 'function',
273+
function: { name: 'part_tool', arguments: '{}' },
274+
},
275+
],
276+
},
277+
])
278+
})
231279
})
232280

233281
// ── client tests ────────────────────────────────────────────────────────────

src/services/langfuse/convert.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,43 @@ type LangfuseInputMessage =
4747
| AssistantMessage
4848
| LangfuseChatMessage
4949

50+
function isRecord(value: unknown): value is Record<string, unknown> {
51+
return value !== null && typeof value === 'object' && !Array.isArray(value)
52+
}
53+
54+
function isLangfuseToolCall(value: unknown): value is LangfuseToolCall {
55+
if (!isRecord(value)) return false
56+
const fn = value.function
57+
return (
58+
typeof value.id === 'string' &&
59+
value.type === 'function' &&
60+
isRecord(fn) &&
61+
typeof fn.name === 'string' &&
62+
typeof fn.arguments === 'string'
63+
)
64+
}
65+
66+
function getToolCalls(value: unknown): LangfuseToolCall[] {
67+
return Array.isArray(value) ? value.filter(isLangfuseToolCall) : []
68+
}
69+
70+
function getContentToolCalls(content: unknown[]): LangfuseToolCall[] {
71+
return content.flatMap(block =>
72+
isRecord(block) ? getToolCalls(block.tool_calls) : [],
73+
)
74+
}
75+
76+
function mergeToolCalls(
77+
...groups: readonly LangfuseToolCall[][]
78+
): LangfuseToolCall[] {
79+
const merged = new Map<string, LangfuseToolCall>()
80+
for (const toolCall of groups.flat()) {
81+
const key = toolCall.id || `${toolCall.function.name}:${toolCall.function.arguments}`
82+
if (!merged.has(key)) merged.set(key, toolCall)
83+
}
84+
return [...merged.values()]
85+
}
86+
5087
/** Normalize a content block into a LangfuseContentPart (non-tool_use, non-tool_result) */
5188
function toContentPart(block: Record<string, unknown>): LangfuseContentPart | null {
5289
const type = block.type as string | undefined
@@ -163,30 +200,34 @@ export function convertMessagesToLangfuse(
163200
isLangfuseRole(inner.role) ? inner.role : isWrappedMessage ? toRole(msg) : 'user'
164201
const rawContent = inner.content
165202
if (typeof rawContent === 'string' || !Array.isArray(rawContent)) {
203+
const toolCalls = getToolCalls(inner.tool_calls)
166204
result.push({
167205
role,
168206
content: String(rawContent ?? ''),
169207
...('tool_call_id' in inner && typeof inner.tool_call_id === 'string'
170208
? { tool_call_id: inner.tool_call_id }
171209
: {}),
172-
...('tool_calls' in inner && Array.isArray(inner.tool_calls)
173-
? { tool_calls: inner.tool_calls as LangfuseToolCall[] }
174-
: {}),
210+
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
175211
})
176212
continue
177213
}
178214

179215
if (role === 'assistant') {
180216
// Extract tool_use → tool_calls at message level
181217
const { tool_calls, rest } = extractToolCalls(rawContent)
218+
const allToolCalls = mergeToolCalls(
219+
tool_calls,
220+
getToolCalls(inner.tool_calls),
221+
getContentToolCalls(rest),
222+
)
182223
const parts = rest
183224
.filter((b): b is Record<string, unknown> => b != null && typeof b === 'object')
184225
.map(b => toContentPart(b))
185226
.filter((p): p is LangfuseContentPart => p !== null)
186227
result.push({
187228
role: 'assistant',
188229
content: collapseContent(parts),
189-
...(tool_calls.length > 0 && { tool_calls }),
230+
...(allToolCalls.length > 0 && { tool_calls: allToolCalls }),
190231
})
191232
} else {
192233
// User messages: extract tool_result → separate tool messages
@@ -196,15 +237,17 @@ export function convertMessagesToLangfuse(
196237
.map(b => toContentPart(b))
197238
.filter((p): p is LangfuseContentPart => p !== null)
198239
if (parts.length > 0 || toolMessages.length === 0) {
240+
const toolCalls = mergeToolCalls(
241+
getToolCalls(inner.tool_calls),
242+
getContentToolCalls(rest),
243+
)
199244
result.push({
200245
role,
201246
content: collapseContent(parts),
202247
...('tool_call_id' in inner && typeof inner.tool_call_id === 'string'
203248
? { tool_call_id: inner.tool_call_id }
204249
: {}),
205-
...('tool_calls' in inner && Array.isArray(inner.tool_calls)
206-
? { tool_calls: inner.tool_calls as LangfuseToolCall[] }
207-
: {}),
250+
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
208251
})
209252
}
210253
result.push(...toolMessages)

0 commit comments

Comments
 (0)