mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 00:05:51 +00:00
feat: 支持 langfuse 工具调用映射
This commit is contained in:
@@ -4,39 +4,92 @@
|
|||||||
* Langfuse generations expect:
|
* Langfuse generations expect:
|
||||||
* input: { role, content }[] where content is string or structured parts
|
* input: { role, content }[] where content is string or structured parts
|
||||||
* output: { role: 'assistant', content: string | part[] }
|
* output: { role: 'assistant', content: string | part[] }
|
||||||
|
*
|
||||||
|
* Key conversions from Anthropic → OpenAI format:
|
||||||
|
* - tool_use blocks → tool_calls[] at message level
|
||||||
|
* - tool_result blocks → separate { role: 'tool' } messages
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Message, AssistantMessage, UserMessage } from 'src/types/message.js'
|
import type { Message, AssistantMessage, UserMessage } from 'src/types/message.js'
|
||||||
|
|
||||||
type LangfuseContentPart =
|
type LangfuseContentPart =
|
||||||
| { type: 'text'; text: string }
|
| { type: 'text'; text: string }
|
||||||
| { type: 'tool_use'; id: string; name: string; input: unknown }
|
|
||||||
| { type: 'tool_result'; tool_use_id: string; content: string }
|
|
||||||
| { type: 'thinking'; thinking: string }
|
| { type: 'thinking'; thinking: string }
|
||||||
| { type: string; [key: string]: unknown }
|
| { type: string; [key: string]: unknown }
|
||||||
|
|
||||||
type LangfuseChatMessage = {
|
type LangfuseToolCall = {
|
||||||
role: 'user' | 'assistant' | 'system'
|
id: string
|
||||||
content: string | LangfuseContentPart[]
|
type: 'function'
|
||||||
|
function: { name: string; arguments: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeContent(content: unknown): string | LangfuseContentPart[] {
|
type LangfuseChatMessage = {
|
||||||
if (typeof content === 'string') return content
|
role: 'user' | 'assistant' | 'system' | 'tool'
|
||||||
if (!Array.isArray(content)) return String(content ?? '')
|
content: string | LangfuseContentPart[] | null
|
||||||
|
tool_calls?: LangfuseToolCall[]
|
||||||
|
tool_call_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
const parts: LangfuseContentPart[] = []
|
/** Normalize a content block into a LangfuseContentPart (non-tool_use, non-tool_result) */
|
||||||
|
function toContentPart(block: Record<string, unknown>): LangfuseContentPart | null {
|
||||||
|
const type = block.type as string | undefined
|
||||||
|
if (type === 'text') {
|
||||||
|
return { type: 'text', text: String(block.text ?? '') }
|
||||||
|
}
|
||||||
|
if (type === 'thinking' || type === 'redacted_thinking') {
|
||||||
|
return { type: 'thinking', thinking: String(block.thinking ?? '[redacted]') }
|
||||||
|
}
|
||||||
|
if (type === 'image') {
|
||||||
|
return { type: 'text', text: '[image]' }
|
||||||
|
}
|
||||||
|
if (type === 'document') {
|
||||||
|
const name = (block.source as Record<string, unknown> | undefined)?.filename
|
||||||
|
?? (block.title as string | undefined)
|
||||||
|
?? 'document'
|
||||||
|
return { type: 'text', text: `[document: ${name}]` }
|
||||||
|
}
|
||||||
|
if (type === 'server_tool_use' || type === 'web_search_tool_result' || type === 'tool_search_tool_result') {
|
||||||
|
return { type, id: String(block.id ?? ''), name: String(block.name ?? type) }
|
||||||
|
}
|
||||||
|
// unknown block: keep type + scalar fields only
|
||||||
|
const safe: Record<string, unknown> = { type: type ?? 'unknown' }
|
||||||
|
for (const [k, v] of Object.entries(block)) {
|
||||||
|
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') safe[k] = v
|
||||||
|
}
|
||||||
|
return safe as LangfuseContentPart
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract tool_use blocks from content into OpenAI-style tool_calls */
|
||||||
|
function extractToolCalls(content: unknown[]): { tool_calls: LangfuseToolCall[]; rest: unknown[] } {
|
||||||
|
const toolCalls: LangfuseToolCall[] = []
|
||||||
|
const rest: unknown[] = []
|
||||||
for (const block of content) {
|
for (const block of content) {
|
||||||
if (!block || typeof block !== 'object') continue
|
if (!block || typeof block !== 'object') { rest.push(block); continue }
|
||||||
const b = block as Record<string, unknown>
|
const b = block as Record<string, unknown>
|
||||||
const type = b.type as string | undefined
|
if (b.type === 'tool_use') {
|
||||||
|
toolCalls.push({
|
||||||
|
id: String(b.id ?? ''),
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: String(b.name ?? ''),
|
||||||
|
arguments: typeof b.input === 'string' ? b.input : JSON.stringify(b.input ?? {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
rest.push(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { tool_calls: toolCalls, rest }
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'text') {
|
/** Extract tool_result blocks into separate { role: 'tool' } messages */
|
||||||
parts.push({ type: 'text', text: String(b.text ?? '') })
|
function extractToolResults(content: unknown[]): { toolMessages: LangfuseChatMessage[]; rest: unknown[] } {
|
||||||
} else if (type === 'thinking' || type === 'redacted_thinking') {
|
const toolMessages: LangfuseChatMessage[] = []
|
||||||
parts.push({ type: 'thinking', thinking: String(b.thinking ?? '[redacted]') })
|
const rest: unknown[] = []
|
||||||
} else if (type === 'tool_use') {
|
for (const block of content) {
|
||||||
parts.push({ type: 'tool_use', id: String(b.id ?? ''), name: String(b.name ?? ''), input: b.input })
|
if (!block || typeof block !== 'object') { rest.push(block); continue }
|
||||||
} else if (type === 'tool_result') {
|
const b = block as Record<string, unknown>
|
||||||
|
if (b.type === 'tool_result') {
|
||||||
const resultContent = Array.isArray(b.content)
|
const resultContent = Array.isArray(b.content)
|
||||||
? (b.content as Record<string, unknown>[])
|
? (b.content as Record<string, unknown>[])
|
||||||
.map(c => {
|
.map(c => {
|
||||||
@@ -47,30 +100,23 @@ function normalizeContent(content: unknown): string | LangfuseContentPart[] {
|
|||||||
})
|
})
|
||||||
.join('\n')
|
.join('\n')
|
||||||
: String(b.content ?? '')
|
: String(b.content ?? '')
|
||||||
parts.push({ type: 'tool_result', tool_use_id: String(b.tool_use_id ?? ''), content: resultContent })
|
toolMessages.push({
|
||||||
} else if (type === 'image') {
|
role: 'tool',
|
||||||
parts.push({ type: 'text', text: '[image]' })
|
tool_call_id: String(b.tool_use_id ?? ''),
|
||||||
} else if (type === 'document') {
|
content: resultContent,
|
||||||
const name = (b.source as Record<string, unknown> | undefined)?.filename
|
})
|
||||||
?? (b.title as string | undefined)
|
|
||||||
?? 'document'
|
|
||||||
parts.push({ type: 'text', text: `[document: ${name}]` })
|
|
||||||
} else if (type === 'server_tool_use' || type === 'web_search_tool_result' || type === 'tool_search_tool_result') {
|
|
||||||
// server-side tool blocks — keep name/id, drop raw content
|
|
||||||
parts.push({ type: type, id: String(b.id ?? ''), name: String(b.name ?? type) })
|
|
||||||
} else {
|
} else {
|
||||||
// unknown block: keep type + scalar fields only, drop any binary/large payloads
|
rest.push(block)
|
||||||
const safe: Record<string, unknown> = { type: type ?? 'unknown' }
|
|
||||||
for (const [k, v] of Object.entries(b)) {
|
|
||||||
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') safe[k] = v
|
|
||||||
}
|
|
||||||
parts.push(safe as LangfuseContentPart)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { toolMessages, rest }
|
||||||
|
}
|
||||||
|
|
||||||
// Collapse to plain string if only one text part
|
/** Collapse content parts: join all-text arrays into a single string */
|
||||||
if (parts.length === 1 && parts[0]!.type === 'text') {
|
function collapseContent(parts: LangfuseContentPart[]): string | LangfuseContentPart[] {
|
||||||
return (parts[0] as { type: 'text'; text: string }).text
|
if (parts.length === 0) return ''
|
||||||
|
if (parts.every(p => p.type === 'text')) {
|
||||||
|
return parts.map(p => (p as { type: 'text'; text: string }).text).join('\n')
|
||||||
}
|
}
|
||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
@@ -96,7 +142,36 @@ export function convertMessagesToLangfuse(
|
|||||||
const inner = msg.message
|
const inner = msg.message
|
||||||
if (!inner) continue
|
if (!inner) continue
|
||||||
const role = (inner.role as 'user' | 'assistant' | undefined) ?? toRole(msg)
|
const role = (inner.role as 'user' | 'assistant' | undefined) ?? toRole(msg)
|
||||||
result.push({ role, content: normalizeContent(inner.content) })
|
const rawContent = inner.content
|
||||||
|
if (typeof rawContent === 'string' || !Array.isArray(rawContent)) {
|
||||||
|
result.push({ role, content: String(rawContent ?? '') })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'assistant') {
|
||||||
|
// Extract tool_use → tool_calls at message level
|
||||||
|
const { tool_calls, rest } = extractToolCalls(rawContent)
|
||||||
|
const parts = rest
|
||||||
|
.filter((b): b is Record<string, unknown> => b != null && typeof b === 'object')
|
||||||
|
.map(b => toContentPart(b))
|
||||||
|
.filter((p): p is LangfuseContentPart => p !== null)
|
||||||
|
result.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: collapseContent(parts),
|
||||||
|
...(tool_calls.length > 0 && { tool_calls }),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// User messages: extract tool_result → separate tool messages
|
||||||
|
const { toolMessages, rest } = extractToolResults(rawContent)
|
||||||
|
const parts = rest
|
||||||
|
.filter((b): b is Record<string, unknown> => b != null && typeof b === 'object')
|
||||||
|
.map(b => toContentPart(b))
|
||||||
|
.filter((p): p is LangfuseContentPart => p !== null)
|
||||||
|
if (parts.length > 0 || toolMessages.length === 0) {
|
||||||
|
result.push({ role: 'user', content: collapseContent(parts) })
|
||||||
|
}
|
||||||
|
result.push(...toolMessages)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -121,12 +196,24 @@ export function convertOutputToLangfuse(
|
|||||||
messages: AssistantMessage[],
|
messages: AssistantMessage[],
|
||||||
): LangfuseChatMessage | LangfuseChatMessage[] | null {
|
): LangfuseChatMessage | LangfuseChatMessage[] | null {
|
||||||
if (messages.length === 0) return null
|
if (messages.length === 0) return null
|
||||||
if (messages.length === 1) {
|
|
||||||
const msg = messages[0]!
|
const convert = (msg: AssistantMessage): LangfuseChatMessage => {
|
||||||
return { role: 'assistant', content: normalizeContent(msg.message?.content) }
|
const rawContent = msg.message?.content
|
||||||
|
if (typeof rawContent === 'string' || !Array.isArray(rawContent)) {
|
||||||
|
return { role: 'assistant', content: String(rawContent ?? '') }
|
||||||
|
}
|
||||||
|
const { tool_calls, rest } = extractToolCalls(rawContent)
|
||||||
|
const parts = rest
|
||||||
|
.filter((b): b is Record<string, unknown> => b != null && typeof b === 'object')
|
||||||
|
.map(b => toContentPart(b))
|
||||||
|
.filter((p): p is LangfuseContentPart => p !== null)
|
||||||
|
return {
|
||||||
|
role: 'assistant',
|
||||||
|
content: collapseContent(parts),
|
||||||
|
...(tool_calls.length > 0 && { tool_calls }),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return messages.map(msg => ({
|
|
||||||
role: 'assistant' as const,
|
if (messages.length === 1) return convert(messages[0]!)
|
||||||
content: normalizeContent(msg.message?.content),
|
return messages.map(convert)
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user