mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
fix(claude-code): prefix tool names to bypass OAuth validation
Anthropic rejects third-party tools when using Claude Code OAuth tokens. This adds an 'oc_' prefix to tool names when sending to the API and strips the prefix from responses, allowing Roo Code tools to work with Claude Code OAuth authentication.
This commit is contained in:
parent
611bb70166
commit
6173606fe3
2 changed files with 271 additions and 6 deletions
|
|
@ -1,6 +1,34 @@
|
|||
import { CLAUDE_CODE_API_CONFIG } from "../streaming-client"
|
||||
import { CLAUDE_CODE_API_CONFIG, prefixToolName, stripToolNamePrefix } from "../streaming-client"
|
||||
|
||||
describe("Claude Code Streaming Client", () => {
|
||||
describe("Tool name prefix utilities", () => {
|
||||
test("prefixToolName should add oc_ prefix to tool names", () => {
|
||||
expect(prefixToolName("read_file")).toBe("oc_read_file")
|
||||
expect(prefixToolName("write_to_file")).toBe("oc_write_to_file")
|
||||
expect(prefixToolName("execute_command")).toBe("oc_execute_command")
|
||||
})
|
||||
|
||||
test("stripToolNamePrefix should remove oc_ prefix from tool names", () => {
|
||||
expect(stripToolNamePrefix("oc_read_file")).toBe("read_file")
|
||||
expect(stripToolNamePrefix("oc_write_to_file")).toBe("write_to_file")
|
||||
expect(stripToolNamePrefix("oc_execute_command")).toBe("execute_command")
|
||||
})
|
||||
|
||||
test("stripToolNamePrefix should return unchanged name if no prefix", () => {
|
||||
expect(stripToolNamePrefix("read_file")).toBe("read_file")
|
||||
expect(stripToolNamePrefix("some_other_tool")).toBe("some_other_tool")
|
||||
})
|
||||
|
||||
test("stripToolNamePrefix should handle edge cases", () => {
|
||||
expect(stripToolNamePrefix("oc_")).toBe("")
|
||||
expect(stripToolNamePrefix("")).toBe("")
|
||||
// "occ_tool" does NOT start with "oc_" exactly, so it's unchanged
|
||||
expect(stripToolNamePrefix("occ_tool")).toBe("occ_tool")
|
||||
// But "oc_oc_tool" would strip one prefix
|
||||
expect(stripToolNamePrefix("oc_oc_tool")).toBe("oc_tool")
|
||||
})
|
||||
})
|
||||
|
||||
describe("CLAUDE_CODE_API_CONFIG", () => {
|
||||
test("should have correct API endpoint", () => {
|
||||
expect(CLAUDE_CODE_API_CONFIG.endpoint).toBe("https://api.anthropic.com/v1/messages")
|
||||
|
|
@ -581,5 +609,165 @@ describe("Claude Code Streaming Client", () => {
|
|||
cacheReadTokens: 5,
|
||||
})
|
||||
})
|
||||
|
||||
test("should prefix tool names when sending to API", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const { createStreamingMessage } = await import("../streaming-client")
|
||||
|
||||
const tools = [
|
||||
{
|
||||
name: "read_file",
|
||||
description: "Read a file",
|
||||
input_schema: { type: "object" as const, properties: {} },
|
||||
},
|
||||
{
|
||||
name: "write_to_file",
|
||||
description: "Write to a file",
|
||||
input_schema: { type: "object" as const, properties: {} },
|
||||
},
|
||||
]
|
||||
|
||||
const stream = createStreamingMessage({
|
||||
accessToken: "test-token",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
systemPrompt: "You are helpful",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
tools,
|
||||
})
|
||||
|
||||
// Consume the stream
|
||||
for await (const _ of stream) {
|
||||
// Just consume
|
||||
}
|
||||
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const body = JSON.parse(call[1].body)
|
||||
|
||||
// Tool names should be prefixed with oc_
|
||||
expect(body.tools).toHaveLength(2)
|
||||
expect(body.tools[0].name).toBe("oc_read_file")
|
||||
expect(body.tools[1].name).toBe("oc_write_to_file")
|
||||
// Other properties should be preserved
|
||||
expect(body.tools[0].description).toBe("Read a file")
|
||||
expect(body.tools[1].description).toBe("Write to a file")
|
||||
})
|
||||
|
||||
test("should prefix tool names in tool_use blocks within messages", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const { createStreamingMessage } = await import("../streaming-client")
|
||||
|
||||
const stream = createStreamingMessage({
|
||||
accessToken: "test-token",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
systemPrompt: "You are helpful",
|
||||
messages: [
|
||||
{ role: "user", content: "Read a file" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_123",
|
||||
name: "read_file",
|
||||
input: { path: "/test.txt" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "tool_123", content: "file contents" }],
|
||||
},
|
||||
] as any,
|
||||
})
|
||||
|
||||
// Consume the stream
|
||||
for await (const _ of stream) {
|
||||
// Just consume
|
||||
}
|
||||
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const body = JSON.parse(call[1].body)
|
||||
|
||||
// Tool use block name should be prefixed
|
||||
const assistantMessage = body.messages[1]
|
||||
expect(assistantMessage.content[0].type).toBe("tool_use")
|
||||
expect(assistantMessage.content[0].name).toBe("oc_read_file")
|
||||
expect(assistantMessage.content[0].id).toBe("tool_123")
|
||||
// Tool result should be unchanged (references tool_use_id, not name)
|
||||
const userMessage = body.messages[2]
|
||||
expect(userMessage.content[0].type).toBe("tool_result")
|
||||
expect(userMessage.content[0].tool_use_id).toBe("tool_123")
|
||||
})
|
||||
|
||||
test("should strip prefix from tool names in streaming responses", async () => {
|
||||
// Simulate a tool_use response from the API with prefixed name
|
||||
const sseData = [
|
||||
'event: content_block_start\ndata: {"index":0,"content_block":{"type":"tool_use","id":"tool_456","name":"oc_execute_command"}}\n\n',
|
||||
'event: content_block_delta\ndata: {"index":0,"delta":{"type":"input_json_delta","partial_json":"{\\"command\\":"}}\n\n',
|
||||
'event: content_block_delta\ndata: {"index":0,"delta":{"type":"input_json_delta","partial_json":"\\"ls\\"}"}}\n\n',
|
||||
"event: message_stop\ndata: {}\n\n",
|
||||
]
|
||||
|
||||
let readIndex = 0
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi.fn().mockImplementation(() => {
|
||||
if (readIndex < sseData.length) {
|
||||
const value = new TextEncoder().encode(sseData[readIndex++])
|
||||
return Promise.resolve({ done: false, value })
|
||||
}
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
}),
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const { createStreamingMessage } = await import("../streaming-client")
|
||||
|
||||
const stream = createStreamingMessage({
|
||||
accessToken: "test-token",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
systemPrompt: "You are helpful",
|
||||
messages: [{ role: "user", content: "List files" }],
|
||||
})
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Find the tool_call_partial chunk with the name
|
||||
const toolCallChunks = chunks.filter((c) => c.type === "tool_call_partial")
|
||||
expect(toolCallChunks.length).toBeGreaterThan(0)
|
||||
|
||||
// The first tool_call_partial should have the stripped name
|
||||
const firstToolCall = toolCallChunks[0] as { type: "tool_call_partial"; name?: string; id?: string }
|
||||
expect(firstToolCall.name).toBe("execute_command") // Prefix stripped
|
||||
expect(firstToolCall.id).toBe("tool_456")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@ import type { Anthropic } from "@anthropic-ai/sdk"
|
|||
import type { ClaudeCodeRateLimitInfo } from "@roo-code/types"
|
||||
import { Package } from "../../shared/package"
|
||||
|
||||
/**
|
||||
* Prefix added to tool names when sending to Claude Code API.
|
||||
* Anthropic rejects third-party tools when using Claude Code OAuth tokens,
|
||||
* so we prefix tool names to bypass their validation and strip the prefix from responses.
|
||||
*/
|
||||
const TOOL_NAME_PREFIX = "oc_"
|
||||
|
||||
/**
|
||||
* Set of content block types that are valid for Anthropic API.
|
||||
* Only these types will be passed through to the API.
|
||||
|
|
@ -18,6 +25,65 @@ const VALID_ANTHROPIC_BLOCK_TYPES = new Set([
|
|||
])
|
||||
|
||||
type ContentBlockWithType = { type: string }
|
||||
type ToolUseBlock = { type: "tool_use"; id: string; name: string; input: unknown }
|
||||
|
||||
/**
|
||||
* Adds the prefix to a tool name for Claude Code API requests.
|
||||
* This bypasses Anthropic's validation that rejects third-party tools.
|
||||
*/
|
||||
export function prefixToolName(name: string): string {
|
||||
return `${TOOL_NAME_PREFIX}${name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the prefix from a tool name received in API responses.
|
||||
* Returns the original tool name for use within Roo Code.
|
||||
*/
|
||||
export function stripToolNamePrefix(name: string): string {
|
||||
if (name.startsWith(TOOL_NAME_PREFIX)) {
|
||||
return name.slice(TOOL_NAME_PREFIX.length)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefixes tool names in the tools array before sending to the API.
|
||||
*/
|
||||
function prefixToolNames(tools: Anthropic.Messages.Tool[]): Anthropic.Messages.Tool[] {
|
||||
return tools.map((tool) => ({
|
||||
...tool,
|
||||
name: prefixToolName(tool.name),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefixes tool names in tool_use blocks within messages.
|
||||
* This ensures consistency when messages containing tool_use blocks are sent back to the API.
|
||||
*/
|
||||
function prefixToolNamesInMessages(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] {
|
||||
return messages.map((message) => {
|
||||
if (typeof message.content === "string") {
|
||||
return message
|
||||
}
|
||||
|
||||
const prefixedContent = message.content.map((block) => {
|
||||
const blockWithType = block as ContentBlockWithType
|
||||
if (blockWithType.type === "tool_use") {
|
||||
const toolUseBlock = block as unknown as ToolUseBlock
|
||||
return {
|
||||
...toolUseBlock,
|
||||
name: prefixToolName(toolUseBlock.name),
|
||||
}
|
||||
}
|
||||
return block
|
||||
})
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: prefixedContent,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out non-Anthropic content blocks from messages before sending to the API.
|
||||
|
|
@ -380,11 +446,16 @@ export async function* createStreamingMessage(options: StreamMessageOptions): As
|
|||
// - We cache the last two user messages for optimal cache hit rates
|
||||
const messagesWithCache = addMessageCacheBreakpoints(sanitizedMessages)
|
||||
|
||||
// Prefix tool names in tool_use blocks within messages for consistency
|
||||
// This ensures that when messages containing tool_use blocks are sent back to the API,
|
||||
// the tool names match the prefixed names we send in the tools array
|
||||
const messagesWithPrefixedTools = prefixToolNamesInMessages(messagesWithCache)
|
||||
|
||||
// Build request body - match Claude Code format exactly
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
stream: true,
|
||||
messages: messagesWithCache,
|
||||
messages: messagesWithPrefixedTools,
|
||||
}
|
||||
|
||||
// Only include max_tokens if explicitly provided
|
||||
|
|
@ -412,7 +483,9 @@ export async function* createStreamingMessage(options: StreamMessageOptions): As
|
|||
}
|
||||
|
||||
if (tools && tools.length > 0) {
|
||||
body.tools = tools
|
||||
// Prefix tool names to bypass Anthropic's third-party tool validation
|
||||
// when using Claude Code OAuth tokens
|
||||
body.tools = prefixToolNames(tools)
|
||||
// Default tool_choice to "auto" when tools are provided (as per spec example)
|
||||
body.tool_choice = toolChoice || { type: "auto" }
|
||||
} else if (toolChoice) {
|
||||
|
|
@ -545,22 +618,26 @@ export async function* createStreamingMessage(options: StreamMessageOptions): As
|
|||
yield { type: "reasoning", text: contentBlock.thinking as string }
|
||||
}
|
||||
break
|
||||
case "tool_use":
|
||||
case "tool_use": {
|
||||
// Strip the prefix from tool names in responses so the rest of the
|
||||
// application sees the original tool names
|
||||
const originalName = stripToolNamePrefix(contentBlock.name as string)
|
||||
contentBlocks.set(index, {
|
||||
type: "tool_use",
|
||||
text: "",
|
||||
id: contentBlock.id as string,
|
||||
name: contentBlock.name as string,
|
||||
name: originalName,
|
||||
arguments: "",
|
||||
})
|
||||
yield {
|
||||
type: "tool_call_partial",
|
||||
index,
|
||||
id: contentBlock.id as string,
|
||||
name: contentBlock.name as string,
|
||||
name: originalName,
|
||||
arguments: undefined,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue