Skip to content

Commit d70e7f7

Browse files
feat: 支持 langfuse 工具调用映射
1 parent 72a2093 commit d70e7f7

1 file changed

Lines changed: 132 additions & 45 deletions

File tree

src/services/langfuse/convert.ts

Lines changed: 132 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,39 +4,92 @@
44
* Langfuse generations expect:
55
* input: { role, content }[] where content is string or structured parts
66
* output: { role: 'assistant', content: string | part[] }
7+
*
8+
* Key conversions from Anthropic → OpenAI format:
9+
* - tool_use blocks → tool_calls[] at message level
10+
* - tool_result blocks → separate { role: 'tool' } messages
711
*/
812

913
import type { Message, AssistantMessage, UserMessage } from 'src/types/message.js'
1014

1115
type LangfuseContentPart =
1216
| { type: 'text'; text: string }
13-
| { type: 'tool_use'; id: string; name: string; input: unknown }
14-
| { type: 'tool_result'; tool_use_id: string; content: string }
1517
| { type: 'thinking'; thinking: string }
1618
| { type: string; [key: string]: unknown }
1719

20+
type LangfuseToolCall = {
21+
id: string
22+
type: 'function'
23+
function: { name: string; arguments: string }
24+
}
25+
1826
type LangfuseChatMessage = {
19-
role: 'user' | 'assistant' | 'system'
20-
content: string | LangfuseContentPart[]
27+
role: 'user' | 'assistant' | 'system' | 'tool'
28+
content: string | LangfuseContentPart[] | null
29+
tool_calls?: LangfuseToolCall[]
30+
tool_call_id?: string
2131
}
2232

23-
function normalizeContent(content: unknown): string | LangfuseContentPart[] {
24-
if (typeof content === 'string') return content
25-
if (!Array.isArray(content)) return String(content ?? '')
33+
/** Normalize a content block into a LangfuseContentPart (non-tool_use, non-tool_result) */
34+
function toContentPart(block: Record<string, unknown>): LangfuseContentPart | null {
35+
const type = block.type as string | undefined
36+
if (type === 'text') {
37+
return { type: 'text', text: String(block.text ?? '') }
38+
}
39+
if (type === 'thinking' || type === 'redacted_thinking') {
40+
return { type: 'thinking', thinking: String(block.thinking ?? '[redacted]') }
41+
}
42+
if (type === 'image') {
43+
return { type: 'text', text: '[image]' }
44+
}
45+
if (type === 'document') {
46+
const name = (block.source as Record<string, unknown> | undefined)?.filename
47+
?? (block.title as string | undefined)
48+
?? 'document'
49+
return { type: 'text', text: `[document: ${name}]` }
50+
}
51+
if (type === 'server_tool_use' || type === 'web_search_tool_result' || type === 'tool_search_tool_result') {
52+
return { type, id: String(block.id ?? ''), name: String(block.name ?? type) }
53+
}
54+
// unknown block: keep type + scalar fields only
55+
const safe: Record<string, unknown> = { type: type ?? 'unknown' }
56+
for (const [k, v] of Object.entries(block)) {
57+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') safe[k] = v
58+
}
59+
return safe as LangfuseContentPart
60+
}
2661

27-
const parts: LangfuseContentPart[] = []
62+
/** Extract tool_use blocks from content into OpenAI-style tool_calls */
63+
function extractToolCalls(content: unknown[]): { tool_calls: LangfuseToolCall[]; rest: unknown[] } {
64+
const toolCalls: LangfuseToolCall[] = []
65+
const rest: unknown[] = []
2866
for (const block of content) {
29-
if (!block || typeof block !== 'object') continue
67+
if (!block || typeof block !== 'object') { rest.push(block); continue }
3068
const b = block as Record<string, unknown>
31-
const type = b.type as string | undefined
69+
if (b.type === 'tool_use') {
70+
toolCalls.push({
71+
id: String(b.id ?? ''),
72+
type: 'function',
73+
function: {
74+
name: String(b.name ?? ''),
75+
arguments: typeof b.input === 'string' ? b.input : JSON.stringify(b.input ?? {}),
76+
},
77+
})
78+
} else {
79+
rest.push(block)
80+
}
81+
}
82+
return { tool_calls: toolCalls, rest }
83+
}
3284

33-
if (type === 'text') {
34-
parts.push({ type: 'text', text: String(b.text ?? '') })
35-
} else if (type === 'thinking' || type === 'redacted_thinking') {
36-
parts.push({ type: 'thinking', thinking: String(b.thinking ?? '[redacted]') })
37-
} else if (type === 'tool_use') {
38-
parts.push({ type: 'tool_use', id: String(b.id ?? ''), name: String(b.name ?? ''), input: b.input })
39-
} else if (type === 'tool_result') {
85+
/** Extract tool_result blocks into separate { role: 'tool' } messages */
86+
function extractToolResults(content: unknown[]): { toolMessages: LangfuseChatMessage[]; rest: unknown[] } {
87+
const toolMessages: LangfuseChatMessage[] = []
88+
const rest: unknown[] = []
89+
for (const block of content) {
90+
if (!block || typeof block !== 'object') { rest.push(block); continue }
91+
const b = block as Record<string, unknown>
92+
if (b.type === 'tool_result') {
4093
const resultContent = Array.isArray(b.content)
4194
? (b.content as Record<string, unknown>[])
4295
.map(c => {
@@ -47,30 +100,23 @@ function normalizeContent(content: unknown): string | LangfuseContentPart[] {
47100
})
48101
.join('\n')
49102
: String(b.content ?? '')
50-
parts.push({ type: 'tool_result', tool_use_id: String(b.tool_use_id ?? ''), content: resultContent })
51-
} else if (type === 'image') {
52-
parts.push({ type: 'text', text: '[image]' })
53-
} else if (type === 'document') {
54-
const name = (b.source as Record<string, unknown> | undefined)?.filename
55-
?? (b.title as string | undefined)
56-
?? 'document'
57-
parts.push({ type: 'text', text: `[document: ${name}]` })
58-
} else if (type === 'server_tool_use' || type === 'web_search_tool_result' || type === 'tool_search_tool_result') {
59-
// server-side tool blocks — keep name/id, drop raw content
60-
parts.push({ type: type, id: String(b.id ?? ''), name: String(b.name ?? type) })
103+
toolMessages.push({
104+
role: 'tool',
105+
tool_call_id: String(b.tool_use_id ?? ''),
106+
content: resultContent,
107+
})
61108
} else {
62-
// unknown block: keep type + scalar fields only, drop any binary/large payloads
63-
const safe: Record<string, unknown> = { type: type ?? 'unknown' }
64-
for (const [k, v] of Object.entries(b)) {
65-
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') safe[k] = v
66-
}
67-
parts.push(safe as LangfuseContentPart)
109+
rest.push(block)
68110
}
69111
}
112+
return { toolMessages, rest }
113+
}
70114

71-
// Collapse to plain string if only one text part
72-
if (parts.length === 1 && parts[0]!.type === 'text') {
73-
return (parts[0] as { type: 'text'; text: string }).text
115+
/** Collapse content parts: join all-text arrays into a single string */
116+
function collapseContent(parts: LangfuseContentPart[]): string | LangfuseContentPart[] {
117+
if (parts.length === 0) return ''
118+
if (parts.every(p => p.type === 'text')) {
119+
return parts.map(p => (p as { type: 'text'; text: string }).text).join('\n')
74120
}
75121
return parts
76122
}
@@ -96,7 +142,36 @@ export function convertMessagesToLangfuse(
96142
const inner = msg.message
97143
if (!inner) continue
98144
const role = (inner.role as 'user' | 'assistant' | undefined) ?? toRole(msg)
99-
result.push({ role, content: normalizeContent(inner.content) })
145+
const rawContent = inner.content
146+
if (typeof rawContent === 'string' || !Array.isArray(rawContent)) {
147+
result.push({ role, content: String(rawContent ?? '') })
148+
continue
149+
}
150+
151+
if (role === 'assistant') {
152+
// Extract tool_use → tool_calls at message level
153+
const { tool_calls, rest } = extractToolCalls(rawContent)
154+
const parts = rest
155+
.filter((b): b is Record<string, unknown> => b != null && typeof b === 'object')
156+
.map(b => toContentPart(b))
157+
.filter((p): p is LangfuseContentPart => p !== null)
158+
result.push({
159+
role: 'assistant',
160+
content: collapseContent(parts),
161+
...(tool_calls.length > 0 && { tool_calls }),
162+
})
163+
} else {
164+
// User messages: extract tool_result → separate tool messages
165+
const { toolMessages, rest } = extractToolResults(rawContent)
166+
const parts = rest
167+
.filter((b): b is Record<string, unknown> => b != null && typeof b === 'object')
168+
.map(b => toContentPart(b))
169+
.filter((p): p is LangfuseContentPart => p !== null)
170+
if (parts.length > 0 || toolMessages.length === 0) {
171+
result.push({ role: 'user', content: collapseContent(parts) })
172+
}
173+
result.push(...toolMessages)
174+
}
100175
}
101176
return result
102177
}
@@ -121,12 +196,24 @@ export function convertOutputToLangfuse(
121196
messages: AssistantMessage[],
122197
): LangfuseChatMessage | LangfuseChatMessage[] | null {
123198
if (messages.length === 0) return null
124-
if (messages.length === 1) {
125-
const msg = messages[0]!
126-
return { role: 'assistant', content: normalizeContent(msg.message?.content) }
199+
200+
const convert = (msg: AssistantMessage): LangfuseChatMessage => {
201+
const rawContent = msg.message?.content
202+
if (typeof rawContent === 'string' || !Array.isArray(rawContent)) {
203+
return { role: 'assistant', content: String(rawContent ?? '') }
204+
}
205+
const { tool_calls, rest } = extractToolCalls(rawContent)
206+
const parts = rest
207+
.filter((b): b is Record<string, unknown> => b != null && typeof b === 'object')
208+
.map(b => toContentPart(b))
209+
.filter((p): p is LangfuseContentPart => p !== null)
210+
return {
211+
role: 'assistant',
212+
content: collapseContent(parts),
213+
...(tool_calls.length > 0 && { tool_calls }),
214+
}
127215
}
128-
return messages.map(msg => ({
129-
role: 'assistant' as const,
130-
content: normalizeContent(msg.message?.content),
131-
}))
216+
217+
if (messages.length === 1) return convert(messages[0]!)
218+
return messages.map(convert)
132219
}

0 commit comments

Comments
 (0)