mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
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:
@@ -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 => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user