mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
fix: 修复 OpenAI provider (gpt-5.4/gpt-5.3-codex等模型)下 内建mcp__plugin_weixin_weixin__reply 微信工具不可见的问题 (#359)
* fix: 修复 OpenAI provider 下 MCP 工具不可见 * docs: 补充 OpenAI MCP 工具列表注释 * fix: 修正 OpenAI Langfuse 输入记录 * refactor: 使用类型守卫收窄 Langfuse role * fix: 保留 Langfuse OpenAI 数组消息角色 * fix: 合并 Langfuse OpenAI tool_calls * fix: 修复 OpenAI Langfuse 类型检查
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, any> | 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(
|
||||
'<available-deferred-tools>\\nmcp__wechat__send_message\\n</available-deferred-tools>',
|
||||
)
|
||||
} finally {
|
||||
_toolSearchEnabled = false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
@@ -32,18 +33,58 @@ 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'
|
||||
|
||||
/**
|
||||
* 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: (AssistantMessage | UserMessage)[],
|
||||
tools: Tools,
|
||||
deferredToolNames: Set<string>,
|
||||
useToolSearch: boolean,
|
||||
): (AssistantMessage | UserMessage)[] {
|
||||
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: `<available-deferred-tools>\n${deferredToolList}\n</available-deferred-tools>`,
|
||||
isMeta: true,
|
||||
}),
|
||||
...messages,
|
||||
]
|
||||
}
|
||||
|
||||
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
|
||||
@@ -176,9 +217,18 @@ export async function* queryModelOpenAI(
|
||||
|
||||
// 8. Convert messages and tools to OpenAI format
|
||||
const enableThinking = isOpenAIThinkingEnabled(openaiModel)
|
||||
const openaiMessages = anthropicMessagesToOpenAI(messagesForAPI, systemPrompt, {
|
||||
enableThinking,
|
||||
})
|
||||
const openAIConvertibleMessages = messagesForAPI.filter(isOpenAIConvertibleMessage)
|
||||
const messagesWithDeferredToolList = prependDeferredToolListIfNeeded(
|
||||
openAIConvertibleMessages,
|
||||
tools,
|
||||
deferredToolNames,
|
||||
useToolSearch,
|
||||
)
|
||||
const openaiMessages = anthropicMessagesToOpenAI(
|
||||
messagesWithDeferredToolList,
|
||||
systemPrompt,
|
||||
{ enableThinking },
|
||||
)
|
||||
const openaiTools = anthropicToolsToOpenAI(standardTools)
|
||||
const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice)
|
||||
|
||||
@@ -356,7 +406,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,
|
||||
|
||||
Reference in New Issue
Block a user