From 9abd22764287833bbe1f6e37f2f11f8199a2cf09 Mon Sep 17 00:00:00 2001 From: loject Date: Wed, 21 Jan 2026 22:25:04 +0700 Subject: [PATCH 1/2] fix(claude-code): workaround reserved tool name collision (read_file) --- src/api/providers/claude-code.ts | 59 ++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/src/api/providers/claude-code.ts b/src/api/providers/claude-code.ts index db7eaae522..bfce6f78fd 100644 --- a/src/api/providers/claude-code.ts +++ b/src/api/providers/claude-code.ts @@ -21,6 +21,47 @@ import { ApiHandlerOptions } from "../../shared/api" import { countTokens } from "../../utils/countTokens" import { convertOpenAIToolsToAnthropic } from "../../core/prompts/tools/native-tools/converters" +/** + * IMPORTANT: + * Claude Code OAuth (sk-ant-oat...) gateway can reject requests when a user-defined tool name + * collides with a reserved/system tool name on their side (observed for "read_file"). + * + * Symptom: the request fails only when a tool is named "read_file", + * but starts working if renamed (e.g. "_read_file"). + * + * Workaround: namespace reserved tool names before sending the request, and map tool call names back + * so the rest of Roo’s tool routing remains unchanged. + */ +const RESERVED_ANTHROPIC_TOOL_NAMES = new Set(["read_file"]) +const RESERVED_ANTHROPIC_TOOL_PREFIX = "_" + +function patchReservedAnthropicToolNames( + tools: T[], +): { + tools: T[] + reverseNameMap: Map +} { + const reverseNameMap = new Map() + + const patchedTools = tools.map((tool) => { + if (!RESERVED_ANTHROPIC_TOOL_NAMES.has(tool.name)) { + return tool + } + + const patchedName = `${RESERVED_ANTHROPIC_TOOL_PREFIX}${tool.name}` + reverseNameMap.set(patchedName, tool.name) + + // Keep the tool shape unchanged, only patch the name + return { ...tool, name: patchedName } as T + }) + + return { tools: patchedTools, reverseNameMap } +} + +function unpatchToolName(name: string, reverseNameMap: Map): string { + return reverseNameMap.get(name) ?? name +} + /** * Converts OpenAI tool_choice to Anthropic ToolChoice format * @param toolChoice - OpenAI tool_choice parameter @@ -144,8 +185,19 @@ export class ClaudeCodeHandler implements ApiHandler, SingleCompletionHandler { // Generate user_id metadata in the format required by Claude Code API const userId = generateUserId(email || undefined) - const anthropicTools = convertOpenAIToolsToAnthropic(metadata?.tools ?? []) - const anthropicToolChoice = convertOpenAIToolChoice(metadata?.tool_choice, metadata?.parallelToolCalls) + const rawAnthropicTools = convertOpenAIToolsToAnthropic(metadata?.tools ?? []) + let anthropicToolChoice = convertOpenAIToolChoice(metadata?.tool_choice, metadata?.parallelToolCalls) + + // Work around reserved tool names in Claude Code OAuth gateway (e.g. "read_file") + const { tools: anthropicTools, reverseNameMap } = patchReservedAnthropicToolNames(rawAnthropicTools) + + // If toolChoice targets a reserved tool, patch it to match the renamed tool list. + if (anthropicToolChoice?.type === "tool" && RESERVED_ANTHROPIC_TOOL_NAMES.has(anthropicToolChoice.name)) { + anthropicToolChoice = { + ...anthropicToolChoice, + name: `${RESERVED_ANTHROPIC_TOOL_PREFIX}${anthropicToolChoice.name}`, + } + } // Determine reasoning effort and thinking configuration const reasoningLevel = this.getReasoningEffort(model.info) @@ -226,7 +278,8 @@ export class ClaudeCodeHandler implements ApiHandler, SingleCompletionHandler { type: "tool_call_partial", index: chunk.index, id: chunk.id, - name: chunk.name, + // chunk.name is expected to always exist for tool calls + name: unpatchToolName(chunk.name as string, reverseNameMap), arguments: chunk.arguments, } break From cb99814e78580eb22e69b7273cce3cc01ce12fbf Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 21 Jan 2026 15:55:12 +0000 Subject: [PATCH 2/2] fix(claude-code): improve type safety and add tests for tool name patching - Fix unpatchToolName() to properly handle undefined tool names - Remove unsafe 'as string' type assertion - Export patching functions and constants for testability - Add comprehensive unit tests (13 new tests) --- .../providers/__tests__/claude-code.spec.ts | 207 +++++++++++++++++- src/api/providers/claude-code.ts | 14 +- 2 files changed, 214 insertions(+), 7 deletions(-) diff --git a/src/api/providers/__tests__/claude-code.spec.ts b/src/api/providers/__tests__/claude-code.spec.ts index 6f5ccbda97..715882ab6d 100644 --- a/src/api/providers/__tests__/claude-code.spec.ts +++ b/src/api/providers/__tests__/claude-code.spec.ts @@ -1,4 +1,10 @@ -import { ClaudeCodeHandler } from "../claude-code" +import { + ClaudeCodeHandler, + patchReservedAnthropicToolNames, + unpatchToolName, + RESERVED_ANTHROPIC_TOOL_NAMES, + RESERVED_ANTHROPIC_TOOL_PREFIX, +} from "../claude-code" import { ApiHandlerOptions } from "../../../shared/api" import type { StreamChunk } from "../../../integrations/claude-code/streaming-client" @@ -604,3 +610,202 @@ describe("ClaudeCodeHandler", () => { }) }) }) + +describe("Reserved Anthropic Tool Name Patching", () => { + describe("RESERVED_ANTHROPIC_TOOL_NAMES", () => { + test("should contain read_file as a reserved name", () => { + expect(RESERVED_ANTHROPIC_TOOL_NAMES.has("read_file")).toBe(true) + }) + }) + + describe("RESERVED_ANTHROPIC_TOOL_PREFIX", () => { + test("should be an underscore", () => { + expect(RESERVED_ANTHROPIC_TOOL_PREFIX).toBe("_") + }) + }) + + describe("patchReservedAnthropicToolNames", () => { + test("should patch reserved tool names with prefix", () => { + const tools = [ + { name: "read_file", description: "Read a file" }, + { name: "write_file", description: "Write a file" }, + ] + + const { tools: patchedTools, reverseNameMap } = patchReservedAnthropicToolNames(tools) + + expect(patchedTools[0].name).toBe("_read_file") + expect(patchedTools[0].description).toBe("Read a file") + expect(patchedTools[1].name).toBe("write_file") // Not patched + expect(patchedTools[1].description).toBe("Write a file") + expect(reverseNameMap.get("_read_file")).toBe("read_file") + expect(reverseNameMap.size).toBe(1) + }) + + test("should not modify non-reserved tool names", () => { + const tools = [ + { name: "write_file", description: "Write a file" }, + { name: "execute_command", description: "Execute a command" }, + ] + + const { tools: patchedTools, reverseNameMap } = patchReservedAnthropicToolNames(tools) + + expect(patchedTools[0].name).toBe("write_file") + expect(patchedTools[1].name).toBe("execute_command") + expect(reverseNameMap.size).toBe(0) + }) + + test("should handle empty tools array", () => { + const { tools: patchedTools, reverseNameMap } = patchReservedAnthropicToolNames([]) + + expect(patchedTools).toHaveLength(0) + expect(reverseNameMap.size).toBe(0) + }) + + test("should preserve additional tool properties", () => { + const tools = [ + { + name: "read_file", + description: "Read a file", + input_schema: { type: "object" as const, properties: {} }, + }, + ] + + const { tools: patchedTools } = patchReservedAnthropicToolNames(tools) + + expect(patchedTools[0]).toEqual({ + name: "_read_file", + description: "Read a file", + input_schema: { type: "object", properties: {} }, + }) + }) + + test("should patch multiple reserved tools if they exist", () => { + // Simulate if there were multiple reserved names + const tools = [ + { name: "read_file", description: "Read" }, + { name: "read_file", description: "Another read" }, // Duplicate name + ] + + const { tools: patchedTools, reverseNameMap } = patchReservedAnthropicToolNames(tools) + + expect(patchedTools[0].name).toBe("_read_file") + expect(patchedTools[1].name).toBe("_read_file") + // Map will only have one entry since keys are unique + expect(reverseNameMap.get("_read_file")).toBe("read_file") + }) + }) + + describe("unpatchToolName", () => { + test("should unpatch a patched tool name using reverse map", () => { + const reverseNameMap = new Map([["_read_file", "read_file"]]) + + expect(unpatchToolName("_read_file", reverseNameMap)).toBe("read_file") + }) + + test("should return original name if not in reverse map", () => { + const reverseNameMap = new Map([["_read_file", "read_file"]]) + + expect(unpatchToolName("write_file", reverseNameMap)).toBe("write_file") + }) + + test("should return undefined when name is undefined", () => { + const reverseNameMap = new Map([["_read_file", "read_file"]]) + + expect(unpatchToolName(undefined, reverseNameMap)).toBeUndefined() + }) + + test("should handle empty reverse map", () => { + const reverseNameMap = new Map() + + expect(unpatchToolName("read_file", reverseNameMap)).toBe("read_file") + }) + }) + + describe("integration: tool name handling in streaming", () => { + test("should pass through tool names unchanged when not in reverse map", async () => { + const handler = new ClaudeCodeHandler({ apiModelId: "claude-sonnet-4-5" }) + const systemPrompt = "You are a helpful assistant" + const messages = [{ role: "user" as const, content: "Read test.txt" }] + + mockGetAccessToken.mockResolvedValue("test-access-token") + + // Mock async generator that yields tool call partial chunks + // Note: The reverseNameMap will only contain entries for tools passed via metadata + // This test verifies non-reserved names pass through unchanged + const mockGenerator = async function* (): AsyncGenerator { + yield { type: "tool_call_partial", index: 0, id: "tool_123", name: "write_file", arguments: undefined } + yield { + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, + arguments: '{"path":"test.txt"}', + } + } + + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) + + const stream = handler.createMessage(systemPrompt, messages) + const results = [] + + for await (const chunk of stream) { + results.push(chunk) + } + + expect(results).toHaveLength(2) + // Non-reserved tool names should pass through unchanged + expect(results[0]).toEqual({ + type: "tool_call_partial", + index: 0, + id: "tool_123", + name: "write_file", + arguments: undefined, + }) + // Continuation chunks with undefined name should remain undefined + expect(results[1]).toEqual({ + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, + arguments: '{"path":"test.txt"}', + }) + }) + + test("should handle undefined tool name in partial chunks", async () => { + const handler = new ClaudeCodeHandler({ apiModelId: "claude-sonnet-4-5" }) + const systemPrompt = "You are a helpful assistant" + const messages = [{ role: "user" as const, content: "Hello" }] + + mockGetAccessToken.mockResolvedValue("test-access-token") + + // Mock a continuation chunk that only has undefined name + const mockGenerator = async function* (): AsyncGenerator { + yield { + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, + arguments: '{"data":"value"}', + } + } + + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) + + const stream = handler.createMessage(systemPrompt, messages) + const results = [] + + for await (const chunk of stream) { + results.push(chunk) + } + + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, + arguments: '{"data":"value"}', + }) + }) + }) +}) diff --git a/src/api/providers/claude-code.ts b/src/api/providers/claude-code.ts index bfce6f78fd..2c0a20ad86 100644 --- a/src/api/providers/claude-code.ts +++ b/src/api/providers/claude-code.ts @@ -32,10 +32,10 @@ import { convertOpenAIToolsToAnthropic } from "../../core/prompts/tools/native-t * Workaround: namespace reserved tool names before sending the request, and map tool call names back * so the rest of Roo’s tool routing remains unchanged. */ -const RESERVED_ANTHROPIC_TOOL_NAMES = new Set(["read_file"]) -const RESERVED_ANTHROPIC_TOOL_PREFIX = "_" +export const RESERVED_ANTHROPIC_TOOL_NAMES = new Set(["read_file"]) +export const RESERVED_ANTHROPIC_TOOL_PREFIX = "_" -function patchReservedAnthropicToolNames( +export function patchReservedAnthropicToolNames( tools: T[], ): { tools: T[] @@ -58,7 +58,10 @@ function patchReservedAnthropicToolNames( return { tools: patchedTools, reverseNameMap } } -function unpatchToolName(name: string, reverseNameMap: Map): string { +export function unpatchToolName(name: string | undefined, reverseNameMap: Map): string | undefined { + if (name === undefined) { + return undefined + } return reverseNameMap.get(name) ?? name } @@ -278,8 +281,7 @@ export class ClaudeCodeHandler implements ApiHandler, SingleCompletionHandler { type: "tool_call_partial", index: chunk.index, id: chunk.id, - // chunk.name is expected to always exist for tool calls - name: unpatchToolName(chunk.name as string, reverseNameMap), + name: unpatchToolName(chunk.name, reverseNameMap), arguments: chunk.arguments, } break