Skip to content

Commit 1171f48

Browse files
feat(langfuse): LLM generation 记录工具定义
将 Anthropic 格式的工具定义转换为 Langfuse 兼容的 OpenAI 格式, 并在 generation 的 input 中以 { messages, tools } 结构传入, 以便在 Langfuse UI 中查看完整的工具定义信息。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 49869ff commit 1171f48

4 files changed

Lines changed: 132 additions & 2 deletions

File tree

src/services/api/claude.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ import { getInitializationStatus } from '../lsp/manager.js'
230230
import { isToolFromMcpServer } from '../mcp/utils.js'
231231
import { recordLLMObservation } from '../langfuse/index.js'
232232
import type { LangfuseSpan } from '../langfuse/index.js'
233-
import { convertMessagesToLangfuse, convertOutputToLangfuse } from '../langfuse/convert.js'
233+
import { convertMessagesToLangfuse, convertOutputToLangfuse, convertToolsToLangfuse } from '../langfuse/convert.js'
234234
import { withStreamingVCR, withVCR } from '../vcr.js'
235235
import { CLIENT_REQUEST_ID_HEADER, getAnthropicClient } from './client.js'
236236
import {
@@ -2916,6 +2916,7 @@ async function* queryModel(
29162916
startTime: new Date(startIncludingRetries),
29172917
endTime: new Date(),
29182918
completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined,
2919+
tools: convertToolsToLangfuse(toolSchemas as unknown[]),
29192920
})
29202921

29212922
void options.getToolPermissionContext().then(permissionContext => {

src/services/langfuse/__tests__/langfuse.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,117 @@ describe('Langfuse integration', () => {
653653
})
654654
})
655655

656+
describe('convertToolsToLangfuse', () => {
657+
test('converts Anthropic tool schema to OpenAI-style format', async () => {
658+
const { convertToolsToLangfuse } = await import('../convert.js')
659+
const tools = [
660+
{
661+
name: 'BashTool',
662+
description: 'Execute a bash command',
663+
input_schema: {
664+
type: 'object',
665+
properties: { command: { type: 'string' } },
666+
required: ['command'],
667+
},
668+
},
669+
]
670+
const result = convertToolsToLangfuse(tools) as Array<Record<string, unknown>>
671+
expect(result).toHaveLength(1)
672+
expect(result[0]).toEqual({
673+
type: 'function',
674+
function: {
675+
name: 'BashTool',
676+
description: 'Execute a bash command',
677+
parameters: {
678+
type: 'object',
679+
properties: { command: { type: 'string' } },
680+
required: ['command'],
681+
},
682+
},
683+
})
684+
})
685+
686+
test('converts multiple tools', async () => {
687+
const { convertToolsToLangfuse } = await import('../convert.js')
688+
const tools = [
689+
{ name: 'ReadTool', description: 'Read a file', input_schema: { type: 'object' } },
690+
{ name: 'WriteTool', description: 'Write a file', input_schema: { type: 'object' } },
691+
]
692+
const result = convertToolsToLangfuse(tools) as Array<Record<string, unknown>>
693+
expect(result).toHaveLength(2)
694+
expect((result[0]!.function as Record<string, unknown>).name).toBe('ReadTool')
695+
expect((result[1]!.function as Record<string, unknown>).name).toBe('WriteTool')
696+
})
697+
698+
test('falls back to parameters when input_schema is missing', async () => {
699+
const { convertToolsToLangfuse } = await import('../convert.js')
700+
const tools = [
701+
{ name: 'Tool1', description: 'desc', parameters: { type: 'object', properties: { a: { type: 'string' } } } },
702+
]
703+
const result = convertToolsToLangfuse(tools) as Array<Record<string, unknown>>
704+
expect((result[0]!.function as Record<string, unknown>).parameters).toEqual({
705+
type: 'object',
706+
properties: { a: { type: 'string' } },
707+
})
708+
})
709+
710+
test('uses empty object when neither input_schema nor parameters exist', async () => {
711+
const { convertToolsToLangfuse } = await import('../convert.js')
712+
const tools = [{ name: 'Tool1', description: 'desc' }]
713+
const result = convertToolsToLangfuse(tools) as Array<Record<string, unknown>>
714+
expect((result[0]!.function as Record<string, unknown>).parameters).toEqual({})
715+
})
716+
717+
test('returns empty array for empty input', async () => {
718+
const { convertToolsToLangfuse } = await import('../convert.js')
719+
expect(convertToolsToLangfuse([])).toEqual([])
720+
})
721+
})
722+
723+
describe('recordLLMObservation with tools', () => {
724+
test('wraps input into { messages, tools } when tools provided', async () => {
725+
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
726+
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
727+
const { createTrace, recordLLMObservation } = await import('../tracing.js')
728+
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
729+
mockStartObservation.mockClear()
730+
const messages = [{ role: 'user', content: 'hello' }]
731+
const tools = [{ type: 'function', function: { name: 'Bash', description: 'Run', parameters: {} } }]
732+
recordLLMObservation(span, {
733+
model: 'claude-3',
734+
provider: 'firstParty',
735+
input: messages,
736+
output: [],
737+
usage: { input_tokens: 10, output_tokens: 5 },
738+
tools,
739+
})
740+
expect(mockStartObservation).toHaveBeenCalledWith('ChatAnthropic', expect.objectContaining({
741+
input: { messages, tools },
742+
}), expect.objectContaining({
743+
asType: 'generation',
744+
}))
745+
})
746+
747+
test('keeps input as-is when tools not provided', async () => {
748+
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
749+
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
750+
const { createTrace, recordLLMObservation } = await import('../tracing.js')
751+
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
752+
mockStartObservation.mockClear()
753+
const messages = [{ role: 'user', content: 'hello' }]
754+
recordLLMObservation(span, {
755+
model: 'claude-3',
756+
provider: 'firstParty',
757+
input: messages,
758+
output: [],
759+
usage: { input_tokens: 10, output_tokens: 5 },
760+
})
761+
expect(mockStartObservation).toHaveBeenCalledWith('ChatAnthropic', expect.objectContaining({
762+
input: messages,
763+
}), expect.any(Object))
764+
})
765+
})
766+
656767
describe('SDK exceptions do not affect main flow', () => {
657768
test('createTrace returns null on SDK error', async () => {
658769
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'

src/services/langfuse/convert.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,21 @@ export function convertMessagesToLangfuse(
101101
return result
102102
}
103103

104+
/** Convert Anthropic-style tool schemas to Langfuse-compatible OpenAI-style tool format */
105+
export function convertToolsToLangfuse(tools: unknown[]): unknown[] {
106+
return tools.map(tool => {
107+
const t = tool as Record<string, unknown>
108+
return {
109+
type: 'function',
110+
function: {
111+
name: t.name,
112+
description: t.description,
113+
parameters: t.input_schema ?? t.parameters ?? {},
114+
},
115+
}
116+
})
117+
}
118+
104119
/** Convert AssistantMessage[] (newMessages) → Langfuse output format (last assistant turn) */
105120
export function convertOutputToLangfuse(
106121
messages: AssistantMessage[],

src/services/langfuse/tracing.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export function recordLLMObservation(
7777
startTime?: Date
7878
endTime?: Date
7979
completionStartTime?: Date
80+
tools?: unknown
8081
},
8182
): void {
8283
if (!rootSpan || !isLangfuseEnabled()) return
@@ -90,7 +91,9 @@ export function recordLLMObservation(
9091
genName,
9192
{
9293
model: params.model,
93-
input: params.input,
94+
input: params.tools
95+
? { messages: params.input, tools: params.tools }
96+
: params.input,
9497
metadata: {
9598
provider: params.provider,
9699
model: params.model,

0 commit comments

Comments
 (0)