From 6f5d5ef5dc4c1c80d9e14cf01d981d8c10e805c9 Mon Sep 17 00:00:00 2001 From: WHX Date: Sat, 25 Apr 2026 11:28:36 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20OpenAI=20provid?= =?UTF-8?q?er=20=E4=B8=8B=20MCP=20=E5=B7=A5=E5=85=B7=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/api/claude.ts | 5 +- .../__tests__/queryModelOpenAI.isolated.ts | 118 +++++++++++++++++- src/services/api/openai/index.ts | 42 ++++++- 3 files changed, 160 insertions(+), 5 deletions(-) diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index ec4dfaeab8..1340657775 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -1340,7 +1340,10 @@ async function* queryModel( // media stripping) but before Anthropic-specific logic (betas, thinking, caching). if (getAPIProvider() === 'openai') { const { queryModelOpenAI } = await import('./openai/index.js') - yield* queryModelOpenAI(messagesForAPI, systemPrompt, filteredTools, signal, options) + // OpenAI emulates Anthropic's dynamic tool loading client-side. It needs + // the full tool pool so ToolSearchTool can search deferred MCP tools that + // were intentionally filtered out of the initial API tool list above. + yield* queryModelOpenAI(messagesForAPI, systemPrompt, tools, signal, options) return } diff --git a/src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts b/src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts index 86ccc5d5d9..23f9dff062 100644 --- a/src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts +++ b/src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts @@ -196,10 +196,52 @@ async function runQueryModel( // We mock at module level. Bun's mock.module replaces the module for the // entire file, so we configure the stream per-test via a shared variable. let _nextEvents: BetaRawMessageStreamEvent[] = [] +let _toolSearchEnabled = false /** Captured arguments from the last chat.completions.create() call */ let _lastCreateArgs: Record | null = null +mock.module('@ant/model-provider', () => ({ + resolveOpenAIModel: (m: string) => m, + adaptOpenAIStreamToAnthropic: (_stream: any, _model: string) => + eventStream(_nextEvents), + anthropicMessagesToOpenAI: (messages: any[]) => + messages.map(msg => ({ + role: msg.message?.role ?? 'user', + content: msg.message?.content ?? '', + })), + anthropicToolsToOpenAI: (tools: any[]) => + tools.map(tool => ({ + type: 'function', + function: { + name: tool.name, + description: tool.description ?? '', + parameters: tool.input_schema ?? { type: 'object', properties: {} }, + }, + })), + anthropicToolChoiceToOpenAI: () => undefined, +})) + +mock.module('../../../../utils/envUtils.js', () => ({ + isEnvTruthy: (value: string | undefined) => + value === '1' || value === 'true' || value === 'yes' || value === 'on', + isEnvDefinedFalsy: (value: string | undefined) => + value === '0' || value === 'false' || value === 'no' || value === 'off', +})) + +mock.module('../../../../services/analytics/growthbook.js', () => ({ + getFeatureValue_CACHED_MAY_BE_STALE: (_key: string, fallback: unknown) => + fallback, +})) + +mock.module('src/bootstrap/state.js', () => ({ + isReplBridgeActive: () => false, +})) + +mock.module('bun:bundle', () => ({ + feature: () => false, +})) + mock.module('../client.js', () => ({ getOpenAIClient: () => ({ chat: { @@ -252,6 +294,13 @@ mock.module('../../../../utils/context.js', () => ({ mock.module('../../../../utils/messages.js', () => ({ normalizeMessagesForAPI: (msgs: any) => msgs, normalizeContentFromAPI: (blocks: any[]) => blocks, + createUserMessage: (opts: any) => ({ + type: 'user', + message: { role: 'user', content: opts.content }, + uuid: 'user-uuid', + timestamp: new Date().toISOString(), + isMeta: opts.isMeta, + }), createAssistantAPIErrorMessage: (opts: any) => ({ type: 'assistant', message: { @@ -268,8 +317,9 @@ mock.module('../../../../utils/api.js', () => ({ })) mock.module('../../../../utils/toolSearch.js', () => ({ - isToolSearchEnabled: async () => false, + isToolSearchEnabled: async () => _toolSearchEnabled, extractDiscoveredToolNames: () => new Set(), + isDeferredToolsDeltaEnabled: () => false, })) mock.module('../../../../tools/ToolSearchTool/prompt.js', () => ({ @@ -297,6 +347,16 @@ mock.module('../../../../utils/modelCost.js', () => ({ getModelPricingString: () => undefined, })) +mock.module('../../../../services/langfuse/tracing.js', () => ({ + recordLLMObservation: () => {}, +})) + +mock.module('../../../../services/langfuse/convert.js', () => ({ + convertMessagesToLangfuse: () => [], + convertOutputToLangfuse: () => ({}), + convertToolsToLangfuse: () => [], +})) + mock.module('../../../../utils/debug.js', () => ({ logForDebugging: () => {}, logAntError: () => {}, @@ -543,3 +603,59 @@ describe('queryModelOpenAI — max_tokens forwarded to request', () => { expect(_lastCreateArgs!.max_tokens).toBe(8192) }) }) + +describe('queryModelOpenAI — deferred MCP tool visibility', () => { + test('prepends available deferred MCP tools to OpenAI messages', async () => { + _toolSearchEnabled = true + _nextEvents = [makeMessageStart(), makeMessageStop()] + + try { + const { queryModelOpenAI } = await import('../index.js') + const tools: any[] = [ + { + name: 'ToolSearch', + isMcp: false, + input_schema: { type: 'object', properties: {} }, + prompt: async () => 'Search deferred tools', + }, + { + name: 'mcp__wechat__send_message', + isMcp: true, + input_schema: { type: 'object', properties: {} }, + prompt: async () => 'Send a WeChat message', + }, + ] + + const options: any = { + model: 'test-model', + tools: [], + agents: [], + querySource: 'main_loop', + getToolPermissionContext: async () => ({ + alwaysAllow: [], + alwaysDeny: [], + needsPermission: [], + mode: 'default', + isBypassingPermissions: false, + }), + } + + for await (const _item of queryModelOpenAI( + [], + { type: 'text', text: '' } as any, + tools as any, + new AbortController().signal, + options, + )) { + // Exhaust generator so request body is built. + } + + expect(_lastCreateArgs).not.toBeNull() + expect(JSON.stringify(_lastCreateArgs!.messages)).toContain( + '\\nmcp__wechat__send_message\\n', + ) + } finally { + _toolSearchEnabled = false + } + }) +}) diff --git a/src/services/api/openai/index.ts b/src/services/api/openai/index.ts index 0db0022252..f773c55b3d 100644 --- a/src/services/api/openai/index.ts +++ b/src/services/api/openai/index.ts @@ -32,18 +32,46 @@ import type { Options } from '../claude.js' import { randomUUID } from 'crypto' import { createAssistantAPIErrorMessage, + createUserMessage, normalizeContentFromAPI, } from '../../../utils/messages.js' import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js' import { isToolSearchEnabled, extractDiscoveredToolNames, + isDeferredToolsDeltaEnabled, } from '../../../utils/toolSearch.js' import { + formatDeferredToolLine, isDeferredTool, TOOL_SEARCH_TOOL_NAME, } from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js' +function prependDeferredToolListIfNeeded( + messages: Message[], + tools: Tools, + deferredToolNames: Set, + useToolSearch: boolean, +): Message[] { + if (!useToolSearch || isDeferredToolsDeltaEnabled()) return messages + + const deferredToolList = tools + .filter(tool => deferredToolNames.has(tool.name)) + .map(formatDeferredToolLine) + .sort() + .join('\n') + + if (!deferredToolList) return messages + + return [ + createUserMessage({ + content: `\n${deferredToolList}\n`, + isMeta: true, + }), + ...messages, + ] +} + /** * Assemble the final AssistantMessage (and optional max_tokens error) from * accumulated stream state. Extracted to avoid duplication between the @@ -176,9 +204,17 @@ export async function* queryModelOpenAI( // 8. Convert messages and tools to OpenAI format const enableThinking = isOpenAIThinkingEnabled(openaiModel) - const openaiMessages = anthropicMessagesToOpenAI(messagesForAPI, systemPrompt, { - enableThinking, - }) + const messagesWithDeferredToolList = prependDeferredToolListIfNeeded( + messagesForAPI, + tools, + deferredToolNames, + useToolSearch, + ) + const openaiMessages = anthropicMessagesToOpenAI( + messagesWithDeferredToolList, + systemPrompt, + { enableThinking }, + ) const openaiTools = anthropicToolsToOpenAI(standardTools) const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice) From b3c9af43c8e38d0590847c3f3686ea3073cc05b3 Mon Sep 17 00:00:00 2001 From: XavierWangHX Date: Sat, 25 Apr 2026 15:28:10 +0800 Subject: [PATCH 2/7] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=20OpenAI=20MCP?= =?UTF-8?q?=20=E5=B7=A5=E5=85=B7=E5=88=97=E8=A1=A8=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/api/openai/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/services/api/openai/index.ts b/src/services/api/openai/index.ts index f773c55b3d..063142976c 100644 --- a/src/services/api/openai/index.ts +++ b/src/services/api/openai/index.ts @@ -47,6 +47,14 @@ import { TOOL_SEARCH_TOOL_NAME, } from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js' +/** + * Mirrors the Anthropic request path's deferred-tool announcement for OpenAI. + * + * OpenAI-compatible endpoints cannot consume Anthropic's `defer_loading` or + * `tool_reference` beta payloads directly, so the model needs the same textual + * list of deferred MCP tool names that Anthropic receives before it can ask + * ToolSearchTool to load their full schemas. + */ function prependDeferredToolListIfNeeded( messages: Message[], tools: Tools, From 67337d5cb56a8f41d62f70e94a7f9b957f9d24cd Mon Sep 17 00:00:00 2001 From: XavierWangHX Date: Sat, 25 Apr 2026 15:45:03 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20OpenAI=20Langfu?= =?UTF-8?q?se=20=E8=BE=93=E5=85=A5=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/api/openai/index.ts | 2 +- .../langfuse/__tests__/langfuse.test.ts | 26 +++++++++++++++++++ src/services/langfuse/convert.ts | 26 +++++++++++++++---- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/services/api/openai/index.ts b/src/services/api/openai/index.ts index 063142976c..0f1b10b3dd 100644 --- a/src/services/api/openai/index.ts +++ b/src/services/api/openai/index.ts @@ -400,7 +400,7 @@ export async function* queryModelOpenAI( recordLLMObservation(options.langfuseTrace ?? null, { model: openaiModel, provider: 'openai', - input: convertMessagesToLangfuse(messagesForAPI, systemPrompt), + input: convertMessagesToLangfuse(openaiMessages), output: convertOutputToLangfuse(collectedMessages), usage: { input_tokens: usage.input_tokens, diff --git a/src/services/langfuse/__tests__/langfuse.test.ts b/src/services/langfuse/__tests__/langfuse.test.ts index 48afa23722..b8edebd159 100644 --- a/src/services/langfuse/__tests__/langfuse.test.ts +++ b/src/services/langfuse/__tests__/langfuse.test.ts @@ -184,6 +184,32 @@ describe('Langfuse integration', () => { }) }) + describe('convertMessagesToLangfuse', () => { + test('preserves OpenAI-style messages including deferred tool announcements', async () => { + const { convertMessagesToLangfuse } = await import('../convert.js') + const result = convertMessagesToLangfuse([ + { + role: 'system', + content: 'system prompt', + }, + { + role: 'user', + content: + '\nmcp__wechat__send_message\n', + }, + ]) + + expect(result).toEqual([ + { role: 'system', content: 'system prompt' }, + { + role: 'user', + content: + '\nmcp__wechat__send_message\n', + }, + ]) + }) + }) + // ── client tests ──────────────────────────────────────────────────────────── describe('isLangfuseEnabled', () => { diff --git a/src/services/langfuse/convert.ts b/src/services/langfuse/convert.ts index 89018e4acd..731603c197 100644 --- a/src/services/langfuse/convert.ts +++ b/src/services/langfuse/convert.ts @@ -30,6 +30,11 @@ type LangfuseChatMessage = { tool_call_id?: string } +type LangfuseInputMessage = + | UserMessage + | AssistantMessage + | LangfuseChatMessage + /** Normalize a content block into a LangfuseContentPart (non-tool_use, non-tool_result) */ function toContentPart(block: Record): LangfuseContentPart | null { const type = block.type as string | undefined @@ -127,9 +132,9 @@ function toRole(msg: Message): 'user' | 'assistant' | 'system' { return 'user' } -/** Convert messagesForAPI (UserMessage | AssistantMessage)[] → Langfuse input format */ +/** Convert internal or OpenAI-style messages → Langfuse input format */ export function convertMessagesToLangfuse( - messages: (UserMessage | AssistantMessage)[], + messages: LangfuseInputMessage[], systemPrompt?: readonly string[], ): LangfuseChatMessage[] { const result: LangfuseChatMessage[] = [] @@ -139,12 +144,23 @@ export function convertMessagesToLangfuse( } } for (const msg of messages) { - const inner = msg.message + const inner = 'message' in msg ? msg.message : msg if (!inner) continue - const role = (inner.role as 'user' | 'assistant' | undefined) ?? toRole(msg) + const role = + (inner.role as 'user' | 'assistant' | 'system' | 'tool' | undefined) ?? + ('message' in msg ? toRole(msg) : 'user') const rawContent = inner.content if (typeof rawContent === 'string' || !Array.isArray(rawContent)) { - result.push({ role, content: String(rawContent ?? '') }) + result.push({ + role, + content: String(rawContent ?? ''), + ...('tool_call_id' in inner && typeof inner.tool_call_id === 'string' + ? { tool_call_id: inner.tool_call_id } + : {}), + ...('tool_calls' in inner && Array.isArray(inner.tool_calls) + ? { tool_calls: inner.tool_calls as LangfuseToolCall[] } + : {}), + }) continue } From 3acd3752b6e8a0ff61097fc46752e177f3e6113c Mon Sep 17 00:00:00 2001 From: XavierWangHX Date: Sat, 25 Apr 2026 15:58:37 +0800 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20=E4=BD=BF=E7=94=A8=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=AE=88=E5=8D=AB=E6=94=B6=E7=AA=84=20Langfuse=20role?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/langfuse/convert.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/services/langfuse/convert.ts b/src/services/langfuse/convert.ts index 731603c197..71eb58e084 100644 --- a/src/services/langfuse/convert.ts +++ b/src/services/langfuse/convert.ts @@ -30,6 +30,18 @@ type LangfuseChatMessage = { tool_call_id?: string } +function isLangfuseRole(value: unknown): value is LangfuseChatMessage['role'] { + switch (value) { + case 'user': + case 'assistant': + case 'system': + case 'tool': + return true + default: + return false + } +} + type LangfuseInputMessage = | UserMessage | AssistantMessage @@ -144,11 +156,11 @@ export function convertMessagesToLangfuse( } } for (const msg of messages) { - const inner = 'message' in msg ? msg.message : msg + const isWrappedMessage = 'message' in msg + const inner = isWrappedMessage ? msg.message : msg if (!inner) continue const role = - (inner.role as 'user' | 'assistant' | 'system' | 'tool' | undefined) ?? - ('message' in msg ? toRole(msg) : 'user') + isLangfuseRole(inner.role) ? inner.role : isWrappedMessage ? toRole(msg) : 'user' const rawContent = inner.content if (typeof rawContent === 'string' || !Array.isArray(rawContent)) { result.push({ From 8f65339fc1d0a2e9a9a4338765af339f34660cbc Mon Sep 17 00:00:00 2001 From: XavierWangHX Date: Sat, 25 Apr 2026 16:21:05 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=20Langfuse=20Open?= =?UTF-8?q?AI=20=E6=95=B0=E7=BB=84=E6=B6=88=E6=81=AF=E8=A7=92=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../langfuse/__tests__/langfuse.test.ts | 20 +++++++++++++++++++ src/services/langfuse/convert.ts | 11 +++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/services/langfuse/__tests__/langfuse.test.ts b/src/services/langfuse/__tests__/langfuse.test.ts index b8edebd159..74dbfc8341 100644 --- a/src/services/langfuse/__tests__/langfuse.test.ts +++ b/src/services/langfuse/__tests__/langfuse.test.ts @@ -208,6 +208,26 @@ describe('Langfuse integration', () => { }, ]) }) + + test('preserves roles for OpenAI-style array content messages', async () => { + const { convertMessagesToLangfuse } = await import('../convert.js') + const result = convertMessagesToLangfuse([ + { + role: 'system', + content: [{ type: 'text', text: 'system reminder' }], + }, + { + role: 'tool', + tool_call_id: 'call_1', + content: [{ type: 'text', text: 'tool output' }], + }, + ]) + + expect(result).toEqual([ + { role: 'system', content: 'system reminder' }, + { role: 'tool', content: 'tool output', tool_call_id: 'call_1' }, + ]) + }) }) // ── client tests ──────────────────────────────────────────────────────────── diff --git a/src/services/langfuse/convert.ts b/src/services/langfuse/convert.ts index 71eb58e084..f6239af4c2 100644 --- a/src/services/langfuse/convert.ts +++ b/src/services/langfuse/convert.ts @@ -196,7 +196,16 @@ export function convertMessagesToLangfuse( .map(b => toContentPart(b)) .filter((p): p is LangfuseContentPart => p !== null) if (parts.length > 0 || toolMessages.length === 0) { - result.push({ role: 'user', content: collapseContent(parts) }) + result.push({ + role, + content: collapseContent(parts), + ...('tool_call_id' in inner && typeof inner.tool_call_id === 'string' + ? { tool_call_id: inner.tool_call_id } + : {}), + ...('tool_calls' in inner && Array.isArray(inner.tool_calls) + ? { tool_calls: inner.tool_calls as LangfuseToolCall[] } + : {}), + }) } result.push(...toolMessages) } From ecb533941bffbcb269763fa384cd4d067e11e496 Mon Sep 17 00:00:00 2001 From: XavierWangHX Date: Sat, 25 Apr 2026 16:40:54 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=E5=90=88=E5=B9=B6=20Langfuse=20Open?= =?UTF-8?q?AI=20tool=5Fcalls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../langfuse/__tests__/langfuse.test.ts | 48 ++++++++++++++++ src/services/langfuse/convert.ts | 57 ++++++++++++++++--- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/src/services/langfuse/__tests__/langfuse.test.ts b/src/services/langfuse/__tests__/langfuse.test.ts index 74dbfc8341..dab33fd60d 100644 --- a/src/services/langfuse/__tests__/langfuse.test.ts +++ b/src/services/langfuse/__tests__/langfuse.test.ts @@ -228,6 +228,54 @@ describe('Langfuse integration', () => { { role: 'tool', content: 'tool output', tool_call_id: 'call_1' }, ]) }) + + test('merges assistant tool calls from OpenAI-style array content', async () => { + const { convertMessagesToLangfuse } = await import('../convert.js') + const result = convertMessagesToLangfuse([ + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'calling a tool', + tool_calls: [ + { + id: 'call_from_part', + type: 'function', + function: { name: 'part_tool', arguments: '{}' }, + }, + ], + }, + ], + tool_calls: [ + { + id: 'call_from_message', + type: 'function', + function: { name: 'message_tool', arguments: '{"ok":true}' }, + }, + ], + }, + ]) + + expect(result).toEqual([ + { + role: 'assistant', + content: 'calling a tool', + tool_calls: [ + { + id: 'call_from_message', + type: 'function', + function: { name: 'message_tool', arguments: '{"ok":true}' }, + }, + { + id: 'call_from_part', + type: 'function', + function: { name: 'part_tool', arguments: '{}' }, + }, + ], + }, + ]) + }) }) // ── client tests ──────────────────────────────────────────────────────────── diff --git a/src/services/langfuse/convert.ts b/src/services/langfuse/convert.ts index f6239af4c2..f77768ee1d 100644 --- a/src/services/langfuse/convert.ts +++ b/src/services/langfuse/convert.ts @@ -47,6 +47,43 @@ type LangfuseInputMessage = | AssistantMessage | LangfuseChatMessage +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function isLangfuseToolCall(value: unknown): value is LangfuseToolCall { + if (!isRecord(value)) return false + const fn = value.function + return ( + typeof value.id === 'string' && + value.type === 'function' && + isRecord(fn) && + typeof fn.name === 'string' && + typeof fn.arguments === 'string' + ) +} + +function getToolCalls(value: unknown): LangfuseToolCall[] { + return Array.isArray(value) ? value.filter(isLangfuseToolCall) : [] +} + +function getContentToolCalls(content: unknown[]): LangfuseToolCall[] { + return content.flatMap(block => + isRecord(block) ? getToolCalls(block.tool_calls) : [], + ) +} + +function mergeToolCalls( + ...groups: readonly LangfuseToolCall[][] +): LangfuseToolCall[] { + const merged = new Map() + for (const toolCall of groups.flat()) { + const key = toolCall.id || `${toolCall.function.name}:${toolCall.function.arguments}` + if (!merged.has(key)) merged.set(key, toolCall) + } + return [...merged.values()] +} + /** Normalize a content block into a LangfuseContentPart (non-tool_use, non-tool_result) */ function toContentPart(block: Record): LangfuseContentPart | null { const type = block.type as string | undefined @@ -163,15 +200,14 @@ export function convertMessagesToLangfuse( isLangfuseRole(inner.role) ? inner.role : isWrappedMessage ? toRole(msg) : 'user' const rawContent = inner.content if (typeof rawContent === 'string' || !Array.isArray(rawContent)) { + const toolCalls = getToolCalls(inner.tool_calls) result.push({ role, content: String(rawContent ?? ''), ...('tool_call_id' in inner && typeof inner.tool_call_id === 'string' ? { tool_call_id: inner.tool_call_id } : {}), - ...('tool_calls' in inner && Array.isArray(inner.tool_calls) - ? { tool_calls: inner.tool_calls as LangfuseToolCall[] } - : {}), + ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), }) continue } @@ -179,6 +215,11 @@ export function convertMessagesToLangfuse( if (role === 'assistant') { // Extract tool_use → tool_calls at message level const { tool_calls, rest } = extractToolCalls(rawContent) + const allToolCalls = mergeToolCalls( + tool_calls, + getToolCalls(inner.tool_calls), + getContentToolCalls(rest), + ) const parts = rest .filter((b): b is Record => b != null && typeof b === 'object') .map(b => toContentPart(b)) @@ -186,7 +227,7 @@ export function convertMessagesToLangfuse( result.push({ role: 'assistant', content: collapseContent(parts), - ...(tool_calls.length > 0 && { tool_calls }), + ...(allToolCalls.length > 0 && { tool_calls: allToolCalls }), }) } else { // User messages: extract tool_result → separate tool messages @@ -196,15 +237,17 @@ export function convertMessagesToLangfuse( .map(b => toContentPart(b)) .filter((p): p is LangfuseContentPart => p !== null) if (parts.length > 0 || toolMessages.length === 0) { + const toolCalls = mergeToolCalls( + getToolCalls(inner.tool_calls), + getContentToolCalls(rest), + ) result.push({ role, content: collapseContent(parts), ...('tool_call_id' in inner && typeof inner.tool_call_id === 'string' ? { tool_call_id: inner.tool_call_id } : {}), - ...('tool_calls' in inner && Array.isArray(inner.tool_calls) - ? { tool_calls: inner.tool_calls as LangfuseToolCall[] } - : {}), + ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), }) } result.push(...toolMessages) From 267bd6a0551b79120a608d54ecbda06ca8d1376f Mon Sep 17 00:00:00 2001 From: XavierWangHX Date: Sat, 25 Apr 2026 18:01:08 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20OpenAI=20Langfu?= =?UTF-8?q?se=20=E7=B1=BB=E5=9E=8B=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/api/openai/index.ts | 12 +++++++++--- src/services/langfuse/convert.ts | 20 ++++++++------------ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/services/api/openai/index.ts b/src/services/api/openai/index.ts index 0f1b10b3dd..248c2dac3b 100644 --- a/src/services/api/openai/index.ts +++ b/src/services/api/openai/index.ts @@ -5,6 +5,7 @@ import type { StreamEvent, SystemAPIErrorMessage, AssistantMessage, + UserMessage, } from '../../../types/message.js' import type { AgentId } from '../../../types/ids.js' import type { Tools } from '../../../Tool.js' @@ -56,11 +57,11 @@ import { * ToolSearchTool to load their full schemas. */ function prependDeferredToolListIfNeeded( - messages: Message[], + messages: (AssistantMessage | UserMessage)[], tools: Tools, deferredToolNames: Set, useToolSearch: boolean, -): Message[] { +): (AssistantMessage | UserMessage)[] { if (!useToolSearch || isDeferredToolsDeltaEnabled()) return messages const deferredToolList = tools @@ -80,6 +81,10 @@ function prependDeferredToolListIfNeeded( ] } +function isOpenAIConvertibleMessage(msg: Message): msg is AssistantMessage | UserMessage { + return msg.type === 'assistant' || msg.type === 'user' +} + /** * Assemble the final AssistantMessage (and optional max_tokens error) from * accumulated stream state. Extracted to avoid duplication between the @@ -212,8 +217,9 @@ export async function* queryModelOpenAI( // 8. Convert messages and tools to OpenAI format const enableThinking = isOpenAIThinkingEnabled(openaiModel) + const openAIConvertibleMessages = messagesForAPI.filter(isOpenAIConvertibleMessage) const messagesWithDeferredToolList = prependDeferredToolListIfNeeded( - messagesForAPI, + openAIConvertibleMessages, tools, deferredToolNames, useToolSearch, diff --git a/src/services/langfuse/convert.ts b/src/services/langfuse/convert.ts index f77768ee1d..ad324c2a0b 100644 --- a/src/services/langfuse/convert.ts +++ b/src/services/langfuse/convert.ts @@ -10,7 +10,7 @@ * - tool_result blocks → separate { role: 'tool' } messages */ -import type { Message, AssistantMessage, UserMessage } from 'src/types/message.js' +import type { AssistantMessage } from 'src/types/message.js' type LangfuseContentPart = | { type: 'text'; text: string } @@ -42,11 +42,6 @@ function isLangfuseRole(value: unknown): value is LangfuseChatMessage['role'] { } } -type LangfuseInputMessage = - | UserMessage - | AssistantMessage - | LangfuseChatMessage - function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } @@ -175,7 +170,7 @@ function collapseContent(parts: LangfuseContentPart[]): string | LangfuseContent return parts } -function toRole(msg: Message): 'user' | 'assistant' | 'system' { +function toRoleFromWrappedMessage(msg: Record): 'user' | 'assistant' | 'system' { if (msg.type === 'assistant') return 'assistant' if (msg.type === 'system') return 'system' return 'user' @@ -183,7 +178,7 @@ function toRole(msg: Message): 'user' | 'assistant' | 'system' { /** Convert internal or OpenAI-style messages → Langfuse input format */ export function convertMessagesToLangfuse( - messages: LangfuseInputMessage[], + messages: readonly unknown[], systemPrompt?: readonly string[], ): LangfuseChatMessage[] { const result: LangfuseChatMessage[] = [] @@ -193,11 +188,12 @@ export function convertMessagesToLangfuse( } } for (const msg of messages) { - const isWrappedMessage = 'message' in msg - const inner = isWrappedMessage ? msg.message : msg - if (!inner) continue + if (!isRecord(msg)) continue + const wrappedMessage = msg.message + const isWrappedMessage = isRecord(wrappedMessage) + const inner = isWrappedMessage ? wrappedMessage : msg const role = - isLangfuseRole(inner.role) ? inner.role : isWrappedMessage ? toRole(msg) : 'user' + isLangfuseRole(inner.role) ? inner.role : isWrappedMessage ? toRoleFromWrappedMessage(msg) : 'user' const rawContent = inner.content if (typeof rawContent === 'string' || !Array.isArray(rawContent)) { const toolCalls = getToolCalls(inner.tool_calls)