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
913import type { Message , AssistantMessage , UserMessage } from 'src/types/message.js'
1014
1115type 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+
1826type 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