feat(langfuse): LLM generation 记录工具定义

将 Anthropic 格式的工具定义转换为 Langfuse 兼容的 OpenAI 格式,
并在 generation 的 input 中以 { messages, tools } 结构传入,
以便在 Langfuse UI 中查看完整的工具定义信息。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-16 15:31:18 +08:00
parent 90027279e6
commit cfab161e28
4 changed files with 132 additions and 2 deletions

View File

@@ -230,7 +230,7 @@ import { getInitializationStatus } from '../lsp/manager.js'
import { isToolFromMcpServer } from '../mcp/utils.js'
import { recordLLMObservation } from '../langfuse/index.js'
import type { LangfuseSpan } from '../langfuse/index.js'
import { convertMessagesToLangfuse, convertOutputToLangfuse } from '../langfuse/convert.js'
import { convertMessagesToLangfuse, convertOutputToLangfuse, convertToolsToLangfuse } from '../langfuse/convert.js'
import { withStreamingVCR, withVCR } from '../vcr.js'
import { CLIENT_REQUEST_ID_HEADER, getAnthropicClient } from './client.js'
import {
@@ -2916,6 +2916,7 @@ async function* queryModel(
startTime: new Date(startIncludingRetries),
endTime: new Date(),
completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined,
tools: convertToolsToLangfuse(toolSchemas as unknown[]),
})
void options.getToolPermissionContext().then(permissionContext => {

View File

@@ -653,6 +653,117 @@ describe('Langfuse integration', () => {
})
})
describe('convertToolsToLangfuse', () => {
test('converts Anthropic tool schema to OpenAI-style format', async () => {
const { convertToolsToLangfuse } = await import('../convert.js')
const tools = [
{
name: 'BashTool',
description: 'Execute a bash command',
input_schema: {
type: 'object',
properties: { command: { type: 'string' } },
required: ['command'],
},
},
]
const result = convertToolsToLangfuse(tools) as Array<Record<string, unknown>>
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
type: 'function',
function: {
name: 'BashTool',
description: 'Execute a bash command',
parameters: {
type: 'object',
properties: { command: { type: 'string' } },
required: ['command'],
},
},
})
})
test('converts multiple tools', async () => {
const { convertToolsToLangfuse } = await import('../convert.js')
const tools = [
{ name: 'ReadTool', description: 'Read a file', input_schema: { type: 'object' } },
{ name: 'WriteTool', description: 'Write a file', input_schema: { type: 'object' } },
]
const result = convertToolsToLangfuse(tools) as Array<Record<string, unknown>>
expect(result).toHaveLength(2)
expect((result[0]!.function as Record<string, unknown>).name).toBe('ReadTool')
expect((result[1]!.function as Record<string, unknown>).name).toBe('WriteTool')
})
test('falls back to parameters when input_schema is missing', async () => {
const { convertToolsToLangfuse } = await import('../convert.js')
const tools = [
{ name: 'Tool1', description: 'desc', parameters: { type: 'object', properties: { a: { type: 'string' } } } },
]
const result = convertToolsToLangfuse(tools) as Array<Record<string, unknown>>
expect((result[0]!.function as Record<string, unknown>).parameters).toEqual({
type: 'object',
properties: { a: { type: 'string' } },
})
})
test('uses empty object when neither input_schema nor parameters exist', async () => {
const { convertToolsToLangfuse } = await import('../convert.js')
const tools = [{ name: 'Tool1', description: 'desc' }]
const result = convertToolsToLangfuse(tools) as Array<Record<string, unknown>>
expect((result[0]!.function as Record<string, unknown>).parameters).toEqual({})
})
test('returns empty array for empty input', async () => {
const { convertToolsToLangfuse } = await import('../convert.js')
expect(convertToolsToLangfuse([])).toEqual([])
})
})
describe('recordLLMObservation with tools', () => {
test('wraps input into { messages, tools } when tools provided', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordLLMObservation } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
mockStartObservation.mockClear()
const messages = [{ role: 'user', content: 'hello' }]
const tools = [{ type: 'function', function: { name: 'Bash', description: 'Run', parameters: {} } }]
recordLLMObservation(span, {
model: 'claude-3',
provider: 'firstParty',
input: messages,
output: [],
usage: { input_tokens: 10, output_tokens: 5 },
tools,
})
expect(mockStartObservation).toHaveBeenCalledWith('ChatAnthropic', expect.objectContaining({
input: { messages, tools },
}), expect.objectContaining({
asType: 'generation',
}))
})
test('keeps input as-is when tools not provided', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordLLMObservation } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
mockStartObservation.mockClear()
const messages = [{ role: 'user', content: 'hello' }]
recordLLMObservation(span, {
model: 'claude-3',
provider: 'firstParty',
input: messages,
output: [],
usage: { input_tokens: 10, output_tokens: 5 },
})
expect(mockStartObservation).toHaveBeenCalledWith('ChatAnthropic', expect.objectContaining({
input: messages,
}), expect.any(Object))
})
})
describe('SDK exceptions do not affect main flow', () => {
test('createTrace returns null on SDK error', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'

View File

@@ -101,6 +101,21 @@ export function convertMessagesToLangfuse(
return result
}
/** Convert Anthropic-style tool schemas to Langfuse-compatible OpenAI-style tool format */
export function convertToolsToLangfuse(tools: unknown[]): unknown[] {
return tools.map(tool => {
const t = tool as Record<string, unknown>
return {
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: t.input_schema ?? t.parameters ?? {},
},
}
})
}
/** Convert AssistantMessage[] (newMessages) → Langfuse output format (last assistant turn) */
export function convertOutputToLangfuse(
messages: AssistantMessage[],

View File

@@ -77,6 +77,7 @@ export function recordLLMObservation(
startTime?: Date
endTime?: Date
completionStartTime?: Date
tools?: unknown
},
): void {
if (!rootSpan || !isLangfuseEnabled()) return
@@ -90,7 +91,9 @@ export function recordLLMObservation(
genName,
{
model: params.model,
input: params.input,
input: params.tools
? { messages: params.input, tools: params.tools }
: params.input,
metadata: {
provider: params.provider,
model: params.model,