From 7b6be8c6d77c49e74e243cacd1e11db02a977f7d Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 2 Dec 2025 23:17:49 +0000 Subject: [PATCH] feat: enable Tool Call support for Qwen Code provider - Add supportsNativeTools flag to qwen3-coder-plus and qwen3-coder-flash models - Update QwenCodeHandler to accept and pass tools to OpenAI client - Add handling for tool_call_partial chunks in streaming response - Only enable tools when toolProtocol is "native" - Add comprehensive test coverage for tool calling functionality Fixes #9761 --- packages/types/src/providers/qwen-code.ts | 2 + src/api/providers/__tests__/qwen-code.spec.ts | 383 ++++++++++++++++++ src/api/providers/qwen-code.ts | 33 +- 3 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 src/api/providers/__tests__/qwen-code.spec.ts diff --git a/packages/types/src/providers/qwen-code.ts b/packages/types/src/providers/qwen-code.ts index 0f51e4eacb..9672d24330 100644 --- a/packages/types/src/providers/qwen-code.ts +++ b/packages/types/src/providers/qwen-code.ts @@ -10,6 +10,7 @@ export const qwenCodeModels = { contextWindow: 1_000_000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0, outputPrice: 0, cacheWritesPrice: 0, @@ -21,6 +22,7 @@ export const qwenCodeModels = { contextWindow: 1_000_000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0, outputPrice: 0, cacheWritesPrice: 0, diff --git a/src/api/providers/__tests__/qwen-code.spec.ts b/src/api/providers/__tests__/qwen-code.spec.ts new file mode 100644 index 0000000000..8bf3b21b76 --- /dev/null +++ b/src/api/providers/__tests__/qwen-code.spec.ts @@ -0,0 +1,383 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" +import { promises as fs } from "node:fs" +import { QwenCodeHandler } from "../qwen-code" +import type { ApiHandlerCreateMessageMetadata } from "../../index" + +// Mock the file system +vi.mock("node:fs", () => ({ + promises: { + readFile: vi.fn(), + writeFile: vi.fn(), + }, +})) + +// Mock OpenAI +vi.mock("openai") + +describe("QwenCodeHandler", () => { + let handler: QwenCodeHandler + let mockCreate: ReturnType + let mockReadFile: ReturnType + + const mockCredentials = { + access_token: "test-access-token", + refresh_token: "test-refresh-token", + token_type: "Bearer", + expiry_date: Date.now() + 3600000, // 1 hour from now + } + + beforeEach(() => { + mockCreate = vi.fn() + mockReadFile = vi.mocked(fs.readFile) + + // Mock credentials file read + mockReadFile.mockResolvedValue(JSON.stringify(mockCredentials)) + + // Mock OpenAI client + vi.mocked(OpenAI).mockImplementation( + () => + ({ + chat: { + completions: { + create: mockCreate, + }, + }, + apiKey: "", + baseURL: "", + }) as any, + ) + + handler = new QwenCodeHandler({ + qwenCodeOauthPath: "/test/credentials.json", + apiModelId: "qwen3-coder-plus", + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("tool calling", () => { + it("should include tools in request when provided in metadata", async () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Help me with something" }] + + const mockTools = [ + { + type: "function" as const, + function: { + name: "get_weather", + description: "Get weather information", + parameters: { + type: "object", + properties: { + location: { type: "string" }, + }, + required: ["location"], + }, + }, + }, + ] + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: mockTools, + toolProtocol: "native", + } + + // Mock stream response + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + usage: { prompt_tokens: 10, completion_tokens: 20 }, + } + }, + })) + + const stream = handler.createMessage(systemPrompt, messages, metadata) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify tools were passed to OpenAI with conversion + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ + name: "get_weather", + description: "Get weather information", + }), + }), + ]), + parallel_tool_calls: false, + }), + ) + }) + + it("should include tool_choice when provided", async () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Help me with something" }] + + const mockTools = [ + { + type: "function" as const, + function: { + name: "get_weather", + description: "Get weather information", + parameters: { + type: "object", + properties: { + location: { type: "string" }, + }, + required: ["location"], + }, + }, + }, + ] + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: mockTools, + toolProtocol: "native", + tool_choice: "auto", + } + + // Mock stream response + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + usage: { prompt_tokens: 10, completion_tokens: 20 }, + } + }, + })) + + const stream = handler.createMessage(systemPrompt, messages, metadata) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify tool_choice was passed to OpenAI + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + tool_choice: "auto", + }), + ) + }) + + it("should yield tool_call_partial chunks when streaming tool calls", async () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "What's the weather?" }] + + const mockTools = [ + { + type: "function" as const, + function: { + name: "get_weather", + description: "Get weather information", + parameters: { + type: "object", + properties: { + location: { type: "string" }, + }, + required: ["location"], + }, + }, + }, + ] + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: mockTools, + toolProtocol: "native", + } + + // Mock stream response with tool calls + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_1", + function: { + name: "get_weather", + arguments: '{"location":', + }, + }, + ], + }, + }, + ], + } + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + function: { + arguments: '"New York"}', + }, + }, + ], + }, + }, + ], + } + yield { + choices: [{ delta: {} }], + usage: { prompt_tokens: 10, completion_tokens: 20 }, + } + }, + })) + + const stream = handler.createMessage(systemPrompt, messages, metadata) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify tool_call_partial chunks were yielded + const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + expect(toolCallChunks).toHaveLength(2) + + expect(toolCallChunks[0]).toEqual({ + type: "tool_call_partial", + index: 0, + id: "call_1", + name: "get_weather", + arguments: '{"location":', + }) + + expect(toolCallChunks[1]).toEqual({ + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, + arguments: '"New York"}', + }) + }) + + it("should set parallel_tool_calls when specified", async () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Help me with multiple things" }, + ] + + const mockTools = [ + { + type: "function" as const, + function: { + name: "tool1", + description: "Tool 1", + parameters: { type: "object", properties: {} }, + }, + }, + ] + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: mockTools, + toolProtocol: "native", + parallelToolCalls: true, + } + + // Mock stream response + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + usage: { prompt_tokens: 10, completion_tokens: 20 }, + } + }, + })) + + const stream = handler.createMessage(systemPrompt, messages, metadata) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify parallel_tool_calls was set to true + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + parallel_tool_calls: true, + }), + ) + }) + + it("should not include tools when toolProtocol is not native", async () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Help me with something" }] + + const mockTools = [ + { + type: "function" as const, + function: { + name: "get_weather", + description: "Get weather information", + parameters: { + type: "object", + properties: { + location: { type: "string" }, + }, + required: ["location"], + }, + }, + }, + ] + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: mockTools, + toolProtocol: "xml", // XML protocol, tools should not be included + } + + // Mock stream response + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + usage: { prompt_tokens: 10, completion_tokens: 20 }, + } + }, + })) + + const stream = handler.createMessage(systemPrompt, messages, metadata) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify tools were NOT passed to OpenAI for XML protocol + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs).not.toHaveProperty("tools") + expect(callArgs).not.toHaveProperty("tool_choice") + expect(callArgs).not.toHaveProperty("parallel_tool_calls") + }) + }) + + describe("getModel", () => { + it("should return correct model info", () => { + const model = handler.getModel() + expect(model.id).toBe("qwen3-coder-plus") + expect(model.info).toMatchObject({ + maxTokens: 65536, + contextWindow: 1000000, + supportsNativeTools: true, + }) + }) + }) +}) diff --git a/src/api/providers/qwen-code.ts b/src/api/providers/qwen-code.ts index d930d9dfc7..da810905c3 100644 --- a/src/api/providers/qwen-code.ts +++ b/src/api/providers/qwen-code.ts @@ -12,7 +12,7 @@ import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" -import type { SingleCompletionHandler } from "../index" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai" const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token` @@ -201,7 +201,11 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan } } - override async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { await this.ensureAuthenticated() const client = this.ensureClient() const model = this.getModel() @@ -220,6 +224,18 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan stream: true, stream_options: { include_usage: true }, max_completion_tokens: model.info.maxTokens, + // Add tools support only for native protocol + ...(metadata?.toolProtocol === "native" && + metadata?.tools && { + tools: this.convertToolsForOpenAI(metadata.tools), + }), + ...(metadata?.toolProtocol === "native" && + metadata?.tool_choice && { + tool_choice: metadata.tool_choice, + }), + ...(metadata?.toolProtocol === "native" && { + parallel_tool_calls: metadata.parallelToolCalls ?? false, + }), } const stream = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions)) @@ -274,6 +290,19 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan } } + // Handle tool calls in the response + if (delta.tool_calls) { + for (const toolCall of delta.tool_calls) { + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, + } + } + } + if (apiChunk.usage) { yield { type: "usage",