fix: Fix deferred tools handling in OpenAI compatibility layer (#193)

* 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.

* fix: 修复OpenAI兼容层中deferred tools处理问题

  提交描述:
  修复了在使用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)能够被正确处理,解决了工具调用失败的问题。
This commit is contained in:
bonerush
2026-04-08 12:56:10 +08:00
committed by GitHub
parent 3683f22529
commit bdea5a2632

View File

@@ -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<string>()
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<string, unknown>
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<number, any> = {}
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)
}