Skip to content
5 changes: 4 additions & 1 deletion src/services/api/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
118 changes: 117 additions & 1 deletion src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand All @@ -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', () => ({
Expand Down Expand Up @@ -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: () => {},
Expand Down Expand Up @@ -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
}
})
})
42 changes: 39 additions & 3 deletions src/services/api/openai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
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: `<available-deferred-tools>\n${deferredToolList}\n</available-deferred-tools>`,
isMeta: true,
}),
...messages,
]
}

/**
* Assemble the final AssistantMessage (and optional max_tokens error) from
* accumulated stream state. Extracted to avoid duplication between the
Expand Down Expand Up @@ -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)

Expand Down