From cfab161e2818db20836e3111715114e1938792aa Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 16 Apr 2026 15:31:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(langfuse):=20LLM=20generation=20=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=B7=A5=E5=85=B7=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 Anthropic 格式的工具定义转换为 Langfuse 兼容的 OpenAI 格式, 并在 generation 的 input 中以 { messages, tools } 结构传入, 以便在 Langfuse UI 中查看完整的工具定义信息。 Co-Authored-By: Claude Opus 4.6 --- src/services/api/claude.ts | 3 +- .../langfuse/__tests__/langfuse.test.ts | 111 ++++++++++++++++++ src/services/langfuse/convert.ts | 15 +++ src/services/langfuse/tracing.ts | 5 +- 4 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 8b3c0e622..9d017206d 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -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 => { diff --git a/src/services/langfuse/__tests__/langfuse.test.ts b/src/services/langfuse/__tests__/langfuse.test.ts index ae286391f..c42f9c9fb 100644 --- a/src/services/langfuse/__tests__/langfuse.test.ts +++ b/src/services/langfuse/__tests__/langfuse.test.ts @@ -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> + 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> + expect(result).toHaveLength(2) + expect((result[0]!.function as Record).name).toBe('ReadTool') + expect((result[1]!.function as Record).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> + expect((result[0]!.function as Record).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> + expect((result[0]!.function as Record).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' diff --git a/src/services/langfuse/convert.ts b/src/services/langfuse/convert.ts index c07de5c94..31594d9cf 100644 --- a/src/services/langfuse/convert.ts +++ b/src/services/langfuse/convert.ts @@ -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 + 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[], diff --git a/src/services/langfuse/tracing.ts b/src/services/langfuse/tracing.ts index 1e06d8ae4..a61acbff1 100644 --- a/src/services/langfuse/tracing.ts +++ b/src/services/langfuse/tracing.ts @@ -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,