diff --git a/docs/openai-task-tools.md b/docs/openai-task-tools.md new file mode 100644 index 000000000..22d415e4b --- /dev/null +++ b/docs/openai-task-tools.md @@ -0,0 +1,190 @@ +# OpenAI兼容模型中task工具使用指南 + +## 问题描述 + +当使用OpenAI兼容模型(如DeepSeek、Ollama、vLLM等)时,调用task工具(TaskGet、TaskCreate、TaskUpdate、TaskList)可能会出现以下错误: + +``` +Error: InputValidationError: TaskGet failed due to the following issues: + The required parameter `taskId` is missing + An unexpected parameter `task_id` was provided + + This tool's schema was not sent to the API — it was not in the discovered-tool set derived from message history. Without the schema in your prompt, typed parameters (arrays, numbers, booleans) get emitted as strings and the client-side parser rejects them. Load the tool first: call ToolSearch with query "select:TaskGet", then retry this call. +``` + +## 问题原因 + +### 1. 延迟加载工具(Deferred Tools) +task工具都是延迟加载的(`shouldDefer: true`),这意味着: +- 工具的模式(schema)不会在初始API调用中发送 +- 需要先通过`ToolSearch`工具发现 +- 只有在被发现后,工具模式才会被发送给API + +### 2. 参数名转换问题 +- task工具使用驼峰命名:`taskId` +- OpenAI兼容模型可能输出蛇形命名:`task_id` +- 当工具模式没有被发送时,模型会猜测参数名,可能导致不匹配 + +## 解决方案 + +### 方案1:先使用ToolSearch(推荐) +在使用task工具之前,先调用`ToolSearch`工具: + +```javascript +// 第一步:发现task工具 +ToolSearch("select:TaskGet,TaskCreate,TaskUpdate,TaskList") + +// 第二步:正常使用task工具 +TaskCreate({ subject: "任务标题", description: "任务描述" }) +TaskGet({ taskId: "1" }) +TaskUpdate({ taskId: "1", status: "completed" }) +TaskList() +``` + +### 方案2:批量发现所有task工具 +```javascript +// 一次性发现所有task工具 +ToolSearch("select:TaskGet,TaskCreate,TaskUpdate,TaskList") + +// 然后可以任意使用task工具 +const task = await TaskCreate({ subject: "新任务", description: "任务描述" }) +console.log(`创建的任务ID: ${task.id}`) + +const taskList = await TaskList() +console.log(`当前有 ${taskList.tasks.length} 个任务`) +``` + +### 方案3:单独发现特定工具 +```javascript +// 只发现需要的工具 +ToolSearch("select:TaskGet") + +// 然后使用该工具 +TaskGet({ taskId: "1" }) +``` + +## 参数名注意事项 + +在使用OpenAI兼容模型时,请注意参数名格式: + +### ✅ 正确(驼峰命名) +```javascript +TaskGet({ taskId: "1" }) +TaskCreate({ subject: "标题", description: "描述" }) +TaskUpdate({ taskId: "1", status: "completed" }) +``` + +### ❌ 错误(蛇形命名) +```javascript +TaskGet({ task_id: "1" }) // 错误:应该使用taskId +TaskCreate({ subject: "标题", description: "描述" }) // 正确 +TaskUpdate({ task_id: "1", status: "completed" }) // 错误:应该使用taskId +``` + +## 常见问题解答 + +### Q1: 为什么需要先使用ToolSearch? +A: task工具是延迟加载的,它们的模式只有在被`ToolSearch`工具发现后才会发送给API。没有工具模式,模型无法知道正确的参数名和类型。 + +### Q2: 每次会话都需要使用ToolSearch吗? +A: 是的,每次新的会话都需要先使用ToolSearch发现工具。工具发现状态不会在会话之间保留。 + +### Q3: 使用Anthropic官方模型也需要这样吗? +A: 通常不需要。Anthropic官方模型对延迟加载工具的处理更智能,但为了兼容性,建议在使用task工具前都先使用ToolSearch。 + +### Q4: 可以一次性发现所有工具吗? +A: 可以,使用`ToolSearch("select:TaskGet,TaskCreate,TaskUpdate,TaskList")`可以一次性发现所有task工具。 + +### Q5: 如果忘记使用ToolSearch会怎样? +A: 会收到参数验证错误,提示需要先使用ToolSearch。按照错误信息的指导操作即可。 + +## 最佳实践 + +1. **会话开始时发现工具**:在开始使用task工具前,先调用ToolSearch +2. **批量发现**:一次性发现所有需要的task工具 +3. **检查参数名**:确保使用正确的驼峰命名参数 +4. **查看错误信息**:如果遇到错误,仔细阅读错误信息中的指导 + +## 示例工作流 + +```javascript +// 1. 开始新会话 +// 2. 发现task工具 +ToolSearch("select:TaskGet,TaskCreate,TaskUpdate,TaskList") + +// 3. 创建任务 +const newTask = await TaskCreate({ + subject: "修复OpenAI兼容性问题", + description: "解决task工具在OpenAI兼容模型下的参数名问题" +}) + +// 4. 获取任务详情 +const taskDetails = await TaskGet({ taskId: newTask.id }) + +// 5. 更新任务状态 +await TaskUpdate({ + taskId: newTask.id, + status: "in_progress", + activeForm: "修复OpenAI兼容性问题" +}) + +// 6. 查看所有任务 +const allTasks = await TaskList() +console.log(`当前有 ${allTasks.tasks.length} 个任务`) + +// 7. 完成任务 +await TaskUpdate({ + taskId: newTask.id, + status: "completed" +}) +``` + +## 故障排除 + +### 错误:参数名不匹配 +**症状**:`taskId`参数缺失,发现`task_id`参数 +**解决**:确保使用驼峰命名的`taskId`,而不是蛇形命名的`task_id` + +### 错误:工具模式未发送 +**症状**:`This tool's schema was not sent to the API` +**解决**:先使用`ToolSearch("select:工具名")`发现工具 + +### 错误:工具不可用 +**症状**:工具调用失败,没有具体错误信息 +**解决**:检查工具是否启用(通过`isTodoV2Enabled()`),确保环境变量设置正确 + +## 相关配置 + +### 环境变量 +```bash +# 启用OpenAI兼容模式 +export CLAUDE_CODE_USE_OPENAI=1 +export OPENAI_API_KEY=your-api-key +export OPENAI_BASE_URL=https://api.deepseek.com + +# 配置模型映射 +export OPENAI_DEFAULT_SONNET_MODEL=deepseek-chat +export OPENAI_DEFAULT_OPUS_MODEL=deepseek-chat +export OPENAI_DEFAULT_HAIKU_MODEL=deepseek-chat +``` + +### 设置文件 +通过`/login`命令配置OpenAI兼容模式后,设置会保存在`~/.claude/settings.json`: +```json +{ + "modelType": "openai", + "openai": { + "baseURL": "https://api.deepseek.com", + "apiKey": "your-api-key", + "models": { + "haiku": "deepseek-chat", + "sonnet": "deepseek-chat", + "opus": "deepseek-chat" + } + } +} +``` + +## 总结 + +在使用OpenAI兼容模型时,task工具需要先通过`ToolSearch`发现才能正常使用。遵循"先发现,后使用"的原则,并注意参数名的正确格式(驼峰命名),可以确保task工具在OpenAI兼容模型下正常工作。 \ No newline at end of file diff --git a/src/services/api/openai/convertMessages.ts b/src/services/api/openai/convertMessages.ts index 051b43d69..3869120eb 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 diff --git a/src/services/api/openai/index.ts b/src/services/api/openai/index.ts index 53734b214..251e89f7d 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) } diff --git a/src/services/tools/toolExecution.ts b/src/services/tools/toolExecution.ts index b4d9c81d1..71a6ea3b8 100644 --- a/src/services/tools/toolExecution.ts +++ b/src/services/tools/toolExecution.ts @@ -589,10 +589,23 @@ export function buildSchemaNotSentHint( if (!isDeferredTool(tool)) return null const discovered = extractDiscoveredToolNames(messages) if (discovered.has(tool.name)) return null + + const toolDisplayName = tool.userFacingName + ? tool.userFacingName(undefined) + : tool.name + return ( - `\n\nThis tool's schema was not sent to the API — it was not in the discovered-tool set derived from message history. ` + - `Without the schema in your prompt, typed parameters (arrays, numbers, booleans) get emitted as strings and the client-side parser rejects them. ` + - `Load the tool first: call ${TOOL_SEARCH_TOOL_NAME} with query "select:${tool.name}", then retry this call.` + `\n\nTool "${toolDisplayName}" is deferred-loading and needs to be discovered before use.\n` + + `When using OpenAI-compatible models (DeepSeek, Ollama, etc.), follow these steps:\n` + + `1. First discover the tool with ToolSearch: ${TOOL_SEARCH_TOOL_NAME}("select:${tool.name}")\n` + + `2. Then call ${toolDisplayName} tool\n` + + `\nExample:\n` + + `${TOOL_SEARCH_TOOL_NAME}("select:${tool.name}") → ${toolDisplayName}({ ... })\n` + + `\nImportant notes:\n` + + `• Use camelCase parameter names (e.g., taskId), not snake_case (task_id)\n` + + `• All task tools (TaskGet, TaskCreate, TaskUpdate, TaskList) need to be discovered first\n` + + `• You can discover them all at once: ${TOOL_SEARCH_TOOL_NAME}("select:TaskGet,TaskCreate,TaskUpdate,TaskList")\n` + + `\nSee docs/openai-task-tools.md for detailed guide.` ) }