From ddac3be48bb43dc0a0e6eb62f5de4f34e49e08a6 Mon Sep 17 00:00:00 2001 From: Bonerush <2630234655@qq.com> Date: Tue, 7 Apr 2026 17:22:49 +0800 Subject: [PATCH 1/2] fix: reorder tool and user messages for OpenAI API compatibility (#168) Fixes #168 OpenAI requires that an assistant message with tool_calls be immediately followed by tool messages. Previously, convertInternalUserMessage output user content before tool results, causing 400 errors. Now tool messages are pushed first. --- src/services/api/openai/convertMessages.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/services/api/openai/convertMessages.ts b/src/services/api/openai/convertMessages.ts index 051b43d693..3869120eb9 100644 --- a/src/services/api/openai/convertMessages.ts +++ b/src/services/api/openai/convertMessages.ts @@ -92,6 +92,15 @@ function convertInternalUserMessage( } } + // CRITICAL: tool messages must come BEFORE any user message in the result. + // OpenAI API requires that a tool message immediately follows the assistant + // message with tool_calls. If we emit a user message first, the API will + // reject the request with "insufficient tool messages following tool_calls". + // See: https://github.com/anthropics/claude-code/issues/xxx + for (const tr of toolResults) { + result.push(convertToolResult(tr)) + } + // 如果有图片,构建多模态 content 数组 if (imageParts.length > 0) { const multiContent: Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }> = [] @@ -109,10 +118,6 @@ function convertInternalUserMessage( content: textParts.join('\n'), } satisfies ChatCompletionUserMessageParam) } - - for (const tr of toolResults) { - result.push(convertToolResult(tr)) - } } return result From 1c8e108a93879b9c5e36c6f42421f03d10297610 Mon Sep 17 00:00:00 2001 From: Bonerush <2630234655@qq.com> Date: Wed, 8 Apr 2026 11:52:35 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DOpenAI=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E5=B1=82=E4=B8=ADdeferred=20tools=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提交描述: 修复了在使用OpenAI兼容API时TaskCreate工具调用失败的问题。 问题: - 当使用OpenAI兼容API模型时,调用TaskCreate工具出现"InputValidationError: The required parameter `subject` is missing"错误 - OpenAI兼容层没有正确处理deferred tools的过滤逻辑,导致工具schema没有被正确发送给模型 修复: 1. 在OpenAI兼容层中添加了与Anthropic API路径一致的deferred tools处理逻辑 2. 导入必要的工具搜索相关函数: isToolSearchEnabled, extractDiscoveredToolNames, isDeferredTool等 3. 实现工具过滤逻辑: - 检查工具搜索是否启用 - 构建deferred tools集合 - 过滤工具列表: 只包含非deferred工具或已发现的deferred工具 - 为deferred tools设置deferLoading标志 4. 修正了extractDiscoveredToolNames函数的导入路径错误 影响: - 解决了TaskCreate工具调用时的参数验证错误 - 确保OpenAI兼容层与Anthropic API路径在处理deferred tools时行为一致 - 支持工具搜索功能在OpenAI兼容模式下正常工作 修改的文件: - src/services/api/openai/index.ts - 主要修复文件 测试建议: 1. 使用OpenAI兼容API模型时,TaskCreate工具应该可以正常调用 2. 如果工具搜索功能启用,可能需要先使用ToolSearchTool来发现TaskCreate工具 3. 验证工具调用时不再出现"InputValidationError"错误 这个修复确保了当使用OpenAI兼容API(如Ollama、DeepSeek、vLLM等)时,deferred tools(如TaskCreate)能够被正确处理,解决了工具调用失败的问题。 --- src/services/api/openai/index.ts | 114 +++++++++++++++++++++++++------ 1 file changed, 94 insertions(+), 20 deletions(-) diff --git a/src/services/api/openai/index.ts b/src/services/api/openai/index.ts index 53734b214a..251e89f7d9 100644 --- a/src/services/api/openai/index.ts +++ b/src/services/api/openai/index.ts @@ -1,15 +1,26 @@ import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' import type { SystemPrompt } from '../../../utils/systemPromptType.js' -import type { Message, StreamEvent, SystemAPIErrorMessage, AssistantMessage } from '../../../types/message.js' +import type { + Message, + StreamEvent, + SystemAPIErrorMessage, + AssistantMessage, +} from '../../../types/message.js' import type { Tools } from '../../../Tool.js' import { getOpenAIClient } from './client.js' import { anthropicMessagesToOpenAI } from './convertMessages.js' -import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from './convertTools.js' +import { + anthropicToolsToOpenAI, + anthropicToolChoiceToOpenAI, +} from './convertTools.js' import { adaptOpenAIStreamToAnthropic } from './streamAdapter.js' import { resolveOpenAIModel } from './modelMapping.js' import { normalizeMessagesForAPI } from '../../../utils/messages.js' import { toolToAPISchema } from '../../../utils/api.js' -import { getEmptyToolPermissionContext } from '../../../Tool.js' +import { + getEmptyToolPermissionContext, + toolMatchesName, +} from '../../../Tool.js' import { logForDebugging } from '../../../utils/debug.js' import { addToTotalSessionCost } from '../../../cost-tracker.js' import { calculateUSDCost } from '../../../utils/modelCost.js' @@ -19,6 +30,14 @@ import { createAssistantAPIErrorMessage, normalizeContentFromAPI, } from '../../../utils/messages.js' +import { + isToolSearchEnabled, + extractDiscoveredToolNames, +} from '../../../utils/toolSearch.js' +import { + isDeferredTool, + TOOL_SEARCH_TOOL_NAME, +} from '../../../tools/ToolSearchTool/prompt.js' /** * OpenAI-compatible query path. Converts Anthropic-format messages/tools to @@ -43,41 +62,97 @@ export async function* queryModelOpenAI( // 2. Normalize messages using shared preprocessing const messagesForAPI = normalizeMessagesForAPI(messages, tools) - // 3. Build tool schemas + // 3. Check if tool search is enabled (similar to Anthropic path) + const useToolSearch = await isToolSearchEnabled( + options.model, + tools, + options.getToolPermissionContext || + (async () => getEmptyToolPermissionContext()), + options.agents || [], + options.querySource, + ) + + // 4. Build deferred tools set (similar to Anthropic path) + const deferredToolNames = new Set() + if (useToolSearch) { + for (const t of tools) { + if (isDeferredTool(t)) deferredToolNames.add(t.name) + } + } + + // 5. Filter tools (similar to Anthropic path) + let filteredTools = tools + if (useToolSearch && deferredToolNames.size > 0) { + const discoveredToolNames = extractDiscoveredToolNames(messages) + + filteredTools = tools.filter(tool => { + // Always include non-deferred tools + if (!deferredToolNames.has(tool.name)) return true + // Always include ToolSearchTool (so it can discover more tools) + if (toolMatchesName(tool, TOOL_SEARCH_TOOL_NAME)) return true + // Only include deferred tools that have been discovered + return discoveredToolNames.has(tool.name) + }) + } + + // 6. Build tool schemas with deferLoading flag const toolSchemas = await Promise.all( - tools.map(tool => + filteredTools.map(tool => toolToAPISchema(tool, { getToolPermissionContext: options.getToolPermissionContext, tools, agents: options.agents, allowedAgentTypes: options.allowedAgentTypes, model: options.model, + deferLoading: useToolSearch && deferredToolNames.has(tool.name), }), ), ) - // Filter out non-standard tools (server tools like advisor) + + // 7. Filter out non-standard tools (server tools like advisor) const standardTools = toolSchemas.filter( (t): t is BetaToolUnion & { type: string } => { const anyT = t as Record - return anyT.type !== 'advisor_20260301' && anyT.type !== 'computer_20250124' + return ( + anyT.type !== 'advisor_20260301' && anyT.type !== 'computer_20250124' + ) }, ) - // 4. Convert messages and tools to OpenAI format - const openaiMessages = anthropicMessagesToOpenAI(messagesForAPI, systemPrompt) + // 8. Convert messages and tools to OpenAI format + const openaiMessages = anthropicMessagesToOpenAI( + messagesForAPI, + systemPrompt, + ) const openaiTools = anthropicToolsToOpenAI(standardTools) const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice) - // 5. Get client and make streaming request + // 9. Log tool filtering details + if (useToolSearch) { + const includedDeferredTools = filteredTools.filter(t => + deferredToolNames.has(t.name), + ).length + logForDebugging( + `[OpenAI] Tool search enabled: ${includedDeferredTools}/${deferredToolNames.size} deferred tools included, total tools=${openaiTools.length}`, + ) + } else { + logForDebugging( + `[OpenAI] Tool search disabled, total tools=${openaiTools.length}`, + ) + } + + // 10. Get client and make streaming request const client = getOpenAIClient({ maxRetries: 0, fetchOverride: options.fetchOverride, source: options.querySource, }) - logForDebugging(`[OpenAI] Calling model=${openaiModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}`) + logForDebugging( + `[OpenAI] Calling model=${openaiModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}`, + ) - // 6. Call OpenAI API with streaming + // 11. Call OpenAI API with streaming const stream = await client.chat.completions.create( { model: openaiModel, @@ -103,7 +178,7 @@ export async function* queryModelOpenAI( // Accumulate content blocks and usage, same as the Anthropic path in claude.ts const contentBlocks: Record = {} - let partialMessage: any = undefined + let partialMessage: any let usage = { input_tokens: 0, output_tokens: 0, @@ -121,7 +196,7 @@ export async function* queryModelOpenAI( if ((event as any).message?.usage) { usage = { ...usage, - ...((event as any).message.usage), + ...(event as any).message.usage, } } break @@ -164,11 +239,7 @@ export async function* queryModelOpenAI( const m: AssistantMessage = { message: { ...partialMessage, - content: normalizeContentFromAPI( - [block], - tools, - options.agentId, - ), + content: normalizeContentFromAPI([block], tools, options.agentId), }, requestId: undefined, type: 'assistant', @@ -192,7 +263,10 @@ export async function* queryModelOpenAI( } // Track cost and token usage (matching the Anthropic path in claude.ts) - if (event.type === 'message_stop' && usage.input_tokens + usage.output_tokens > 0) { + if ( + event.type === 'message_stop' && + usage.input_tokens + usage.output_tokens > 0 + ) { const costUSD = calculateUSDCost(openaiModel, usage as any) addToTotalSessionCost(costUSD, usage as any, options.model) }