mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 00:05: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 { isToolFromMcpServer } from '../mcp/utils.js'
|
||||||
import { recordLLMObservation } from '../langfuse/index.js'
|
import { recordLLMObservation } from '../langfuse/index.js'
|
||||||
import type { LangfuseSpan } 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 { withStreamingVCR, withVCR } from '../vcr.js'
|
||||||
import { CLIENT_REQUEST_ID_HEADER, getAnthropicClient } from './client.js'
|
import { CLIENT_REQUEST_ID_HEADER, getAnthropicClient } from './client.js'
|
||||||
import {
|
import {
|
||||||
@@ -2916,6 +2916,7 @@ async function* queryModel(
|
|||||||
startTime: new Date(startIncludingRetries),
|
startTime: new Date(startIncludingRetries),
|
||||||
endTime: new Date(),
|
endTime: new Date(),
|
||||||
completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined,
|
completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined,
|
||||||
|
tools: convertToolsToLangfuse(toolSchemas as unknown[]),
|
||||||
})
|
})
|
||||||
|
|
||||||
void options.getToolPermissionContext().then(permissionContext => {
|
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', () => {
|
describe('SDK exceptions do not affect main flow', () => {
|
||||||
test('createTrace returns null on SDK error', async () => {
|
test('createTrace returns null on SDK error', async () => {
|
||||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
||||||
|
|||||||
@@ -101,6 +101,21 @@ export function convertMessagesToLangfuse(
|
|||||||
return result
|
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) */
|
/** Convert AssistantMessage[] (newMessages) → Langfuse output format (last assistant turn) */
|
||||||
export function convertOutputToLangfuse(
|
export function convertOutputToLangfuse(
|
||||||
messages: AssistantMessage[],
|
messages: AssistantMessage[],
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export function recordLLMObservation(
|
|||||||
startTime?: Date
|
startTime?: Date
|
||||||
endTime?: Date
|
endTime?: Date
|
||||||
completionStartTime?: Date
|
completionStartTime?: Date
|
||||||
|
tools?: unknown
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
if (!rootSpan || !isLangfuseEnabled()) return
|
if (!rootSpan || !isLangfuseEnabled()) return
|
||||||
@@ -90,7 +91,9 @@ export function recordLLMObservation(
|
|||||||
genName,
|
genName,
|
||||||
{
|
{
|
||||||
model: params.model,
|
model: params.model,
|
||||||
input: params.input,
|
input: params.tools
|
||||||
|
? { messages: params.input, tools: params.tools }
|
||||||
|
: params.input,
|
||||||
metadata: {
|
metadata: {
|
||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
model: params.model,
|
model: params.model,
|
||||||
|
|||||||
Reference in New Issue
Block a user