From 4bfd6c020b3c8bc473b262e83beea0968f337396 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 19 Dec 2025 08:31:31 -0700 Subject: [PATCH 1/4] feat(providers): add native tool calling support to LM Studio and Qwen-Code - Add supportsNativeTools: true and defaultToolProtocol: native to Qwen-Code model types - Add metadata parameter and native tools handling to Qwen-Code provider - Add native tools handling to LM Studio provider (already had metadata param) - Both providers now emit tool_call_partial and tool_call_end events - Add comprehensive test suites for both providers (15 tests total) - Follows established patterns from xAI and DeepInfra providers --- packages/types/src/providers/qwen-code.ts | 4 + .../__tests__/lmstudio-native-tools.spec.ts | 376 ++++++++++++++++++ .../__tests__/qwen-code-native-tools.spec.ts | 373 +++++++++++++++++ src/api/providers/lm-studio.ts | 34 +- src/api/providers/qwen-code.ts | 40 +- 5 files changed, 824 insertions(+), 3 deletions(-) create mode 100644 src/api/providers/__tests__/lmstudio-native-tools.spec.ts create mode 100644 src/api/providers/__tests__/qwen-code-native-tools.spec.ts diff --git a/packages/types/src/providers/qwen-code.ts b/packages/types/src/providers/qwen-code.ts index 0f51e4eacbe..e1102011aab 100644 --- a/packages/types/src/providers/qwen-code.ts +++ b/packages/types/src/providers/qwen-code.ts @@ -10,6 +10,8 @@ export const qwenCodeModels = { contextWindow: 1_000_000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, cacheWritesPrice: 0, @@ -21,6 +23,8 @@ export const qwenCodeModels = { contextWindow: 1_000_000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, cacheWritesPrice: 0, diff --git a/src/api/providers/__tests__/lmstudio-native-tools.spec.ts b/src/api/providers/__tests__/lmstudio-native-tools.spec.ts new file mode 100644 index 00000000000..c2d1a92ec19 --- /dev/null +++ b/src/api/providers/__tests__/lmstudio-native-tools.spec.ts @@ -0,0 +1,376 @@ +// npx vitest run api/providers/__tests__/lmstudio-native-tools.spec.ts + +// Mock OpenAI client - must come before other imports +const mockCreate = vi.fn() +vi.mock("openai", () => { + return { + __esModule: true, + default: vi.fn().mockImplementation(() => ({ + chat: { + completions: { + create: mockCreate, + }, + }, + })), + } +}) + +import { LmStudioHandler } from "../lm-studio" +import { NativeToolCallParser } from "../../../core/assistant-message/NativeToolCallParser" +import type { ApiHandlerOptions } from "../../../shared/api" + +describe("LmStudioHandler Native Tools", () => { + let handler: LmStudioHandler + let mockOptions: ApiHandlerOptions + + const testTools = [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { + type: "object", + properties: { + arg1: { type: "string", description: "First argument" }, + }, + required: ["arg1"], + }, + }, + }, + ] + + beforeEach(() => { + vi.clearAllMocks() + + mockOptions = { + apiModelId: "local-model", + lmStudioModelId: "local-model", + lmStudioBaseUrl: "http://localhost:1234", + } + handler = new LmStudioHandler(mockOptions) + + // Clear NativeToolCallParser state before each test + NativeToolCallParser.clearRawChunkState() + }) + + describe("Native Tool Calling Support", () => { + it("should include tools in request when model supports native tools and tools are provided", async () => { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + }) + await stream.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ + name: "test_tool", + }), + }), + ]), + parallel_tool_calls: false, + }), + ) + }) + + it("should include tool_choice when provided", async () => { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + tool_choice: "auto", + }) + await stream.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + tool_choice: "auto", + }), + ) + }) + + it("should not include tools when toolProtocol is xml", async () => { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + toolProtocol: "xml", + }) + await stream.next() + + const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0] + expect(callArgs).not.toHaveProperty("tools") + expect(callArgs).not.toHaveProperty("tool_choice") + }) + + it("should yield tool_call_partial chunks during streaming", async () => { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_lmstudio_123", + function: { + name: "test_tool", + arguments: '{"arg1":', + }, + }, + ], + }, + }, + ], + } + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + function: { + arguments: '"value"}', + }, + }, + ], + }, + }, + ], + } + }, + })) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + }) + + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks).toContainEqual({ + type: "tool_call_partial", + index: 0, + id: "call_lmstudio_123", + name: "test_tool", + arguments: '{"arg1":', + }) + + expect(chunks).toContainEqual({ + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, + arguments: '"value"}', + }) + }) + + it("should set parallel_tool_calls based on metadata", async () => { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + parallelToolCalls: true, + }) + await stream.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + parallel_tool_calls: true, + }), + ) + }) + + it("should yield tool_call_end events when finish_reason is tool_calls", async () => { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_lmstudio_test", + function: { + name: "test_tool", + arguments: '{"arg1":"value"}', + }, + }, + ], + }, + }, + ], + } + yield { + choices: [ + { + delta: {}, + finish_reason: "tool_calls", + }, + ], + } + }, + })) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + }) + + const chunks = [] + for await (const chunk of stream) { + // Simulate what Task.ts does: when we receive tool_call_partial, + // process it through NativeToolCallParser to populate rawChunkTracker + if (chunk.type === "tool_call_partial") { + NativeToolCallParser.processRawChunk({ + index: chunk.index, + id: chunk.id, + name: chunk.name, + arguments: chunk.arguments, + }) + } + chunks.push(chunk) + } + + // Should have tool_call_partial and tool_call_end + const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + + expect(partialChunks).toHaveLength(1) + expect(endChunks).toHaveLength(1) + expect(endChunks[0].id).toBe("call_lmstudio_test") + }) + + it("should work with parallel tool calls disabled", async () => { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Response" } }], + } + }, + })) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + parallelToolCalls: false, + }) + await stream.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + parallel_tool_calls: false, + }), + ) + }) + + it("should handle reasoning content alongside tool calls", async () => { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + content: "Thinking about this...", + }, + }, + ], + } + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_after_think", + function: { + name: "test_tool", + arguments: '{"arg1":"result"}', + }, + }, + ], + }, + }, + ], + } + yield { + choices: [ + { + delta: {}, + finish_reason: "tool_calls", + }, + ], + } + }, + })) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + }) + + const chunks = [] + for await (const chunk of stream) { + if (chunk.type === "tool_call_partial") { + NativeToolCallParser.processRawChunk({ + index: chunk.index, + id: chunk.id, + name: chunk.name, + arguments: chunk.arguments, + }) + } + chunks.push(chunk) + } + + // Should have reasoning, tool_call_partial, and tool_call_end + const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") + const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + + expect(reasoningChunks).toHaveLength(1) + expect(reasoningChunks[0].text).toBe("Thinking about this...") + expect(partialChunks).toHaveLength(1) + expect(endChunks).toHaveLength(1) + }) + }) +}) diff --git a/src/api/providers/__tests__/qwen-code-native-tools.spec.ts b/src/api/providers/__tests__/qwen-code-native-tools.spec.ts new file mode 100644 index 00000000000..d6766dafd6e --- /dev/null +++ b/src/api/providers/__tests__/qwen-code-native-tools.spec.ts @@ -0,0 +1,373 @@ +// npx vitest run api/providers/__tests__/qwen-code-native-tools.spec.ts + +// Mock filesystem - must come before other imports +vi.mock("node:fs", () => ({ + promises: { + readFile: vi.fn(), + writeFile: vi.fn(), + }, +})) + +const mockCreate = vi.fn() +vi.mock("openai", () => { + return { + __esModule: true, + default: vi.fn().mockImplementation(() => ({ + apiKey: "test-key", + baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", + chat: { + completions: { + create: mockCreate, + }, + }, + })), + } +}) + +import { promises as fs } from "node:fs" +import { QwenCodeHandler } from "../qwen-code" +import { NativeToolCallParser } from "../../../core/assistant-message/NativeToolCallParser" +import type { ApiHandlerOptions } from "../../../shared/api" + +describe("QwenCodeHandler Native Tools", () => { + let handler: QwenCodeHandler + let mockOptions: ApiHandlerOptions & { qwenCodeOauthPath?: string } + + const testTools = [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { + type: "object", + properties: { + arg1: { type: "string", description: "First argument" }, + }, + required: ["arg1"], + }, + }, + }, + ] + + beforeEach(() => { + vi.clearAllMocks() + + // Mock credentials file + const mockCredentials = { + access_token: "test-access-token", + refresh_token: "test-refresh-token", + token_type: "Bearer", + expiry_date: Date.now() + 3600000, // 1 hour from now + resource_url: "https://dashscope.aliyuncs.com/compatible-mode/v1", + } + ;(fs.readFile as any).mockResolvedValue(JSON.stringify(mockCredentials)) + ;(fs.writeFile as any).mockResolvedValue(undefined) + + mockOptions = { + apiModelId: "qwen3-coder-plus", + } + handler = new QwenCodeHandler(mockOptions) + + // Clear NativeToolCallParser state before each test + NativeToolCallParser.clearRawChunkState() + }) + + describe("Native Tool Calling Support", () => { + it("should include tools in request when model supports native tools and tools are provided", async () => { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + }) + await stream.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ + name: "test_tool", + }), + }), + ]), + parallel_tool_calls: false, + }), + ) + }) + + it("should include tool_choice when provided", async () => { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + tool_choice: "auto", + }) + await stream.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + tool_choice: "auto", + }), + ) + }) + + it("should not include tools when toolProtocol is xml", async () => { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + toolProtocol: "xml", + }) + await stream.next() + + const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0] + expect(callArgs).not.toHaveProperty("tools") + expect(callArgs).not.toHaveProperty("tool_choice") + }) + + it("should yield tool_call_partial chunks during streaming", async () => { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_qwen_123", + function: { + name: "test_tool", + arguments: '{"arg1":', + }, + }, + ], + }, + }, + ], + } + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + function: { + arguments: '"value"}', + }, + }, + ], + }, + }, + ], + } + }, + })) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + }) + + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks).toContainEqual({ + type: "tool_call_partial", + index: 0, + id: "call_qwen_123", + name: "test_tool", + arguments: '{"arg1":', + }) + + expect(chunks).toContainEqual({ + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, + arguments: '"value"}', + }) + }) + + it("should set parallel_tool_calls based on metadata", async () => { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + parallelToolCalls: true, + }) + await stream.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + parallel_tool_calls: true, + }), + ) + }) + + it("should yield tool_call_end events when finish_reason is tool_calls", async () => { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_qwen_test", + function: { + name: "test_tool", + arguments: '{"arg1":"value"}', + }, + }, + ], + }, + }, + ], + } + yield { + choices: [ + { + delta: {}, + finish_reason: "tool_calls", + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + } + }, + })) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + }) + + const chunks = [] + for await (const chunk of stream) { + // Simulate what Task.ts does: when we receive tool_call_partial, + // process it through NativeToolCallParser to populate rawChunkTracker + if (chunk.type === "tool_call_partial") { + NativeToolCallParser.processRawChunk({ + index: chunk.index, + id: chunk.id, + name: chunk.name, + arguments: chunk.arguments, + }) + } + chunks.push(chunk) + } + + // Should have tool_call_partial and tool_call_end + const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + + expect(partialChunks).toHaveLength(1) + expect(endChunks).toHaveLength(1) + expect(endChunks[0].id).toBe("call_qwen_test") + }) + + it("should preserve thinking block handling alongside tool calls", async () => { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + reasoning_content: "Thinking about this...", + }, + }, + ], + } + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_after_think", + function: { + name: "test_tool", + arguments: '{"arg1":"result"}', + }, + }, + ], + }, + }, + ], + } + yield { + choices: [ + { + delta: {}, + finish_reason: "tool_calls", + }, + ], + } + }, + })) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + }) + + const chunks = [] + for await (const chunk of stream) { + if (chunk.type === "tool_call_partial") { + NativeToolCallParser.processRawChunk({ + index: chunk.index, + id: chunk.id, + name: chunk.name, + arguments: chunk.arguments, + }) + } + chunks.push(chunk) + } + + // Should have reasoning, tool_call_partial, and tool_call_end + const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") + const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + + expect(reasoningChunks).toHaveLength(1) + expect(reasoningChunks[0].text).toBe("Thinking about this...") + expect(partialChunks).toHaveLength(1) + expect(endChunks).toHaveLength(1) + }) + }) +}) diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index 6c58a96ae1f..26ea3636124 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -6,6 +6,7 @@ import { type ModelInfo, openAiModelInfoSaneDefaults, LMSTUDIO_DEFAULT_TEMPERATU import type { ApiHandlerOptions } from "../../shared/api" +import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser" import { XmlMatcher } from "../../utils/xml-matcher" import { convertToOpenAiMessages } from "../transform/openai-format" @@ -13,7 +14,7 @@ import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { getModels, getModelsFromCache } from "./fetchers/modelCache" +import { getModelsFromCache } from "./fetchers/modelCache" import { getApiRequestTimeout } from "./utils/timeout-config" import { handleOpenAIError } from "./utils/openai-error-handler" @@ -46,6 +47,12 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan ...convertToOpenAiMessages(messages), ] + // Check if model supports native tools and tools are provided with native protocol + const modelInfo = this.getModel().info + const supportsNativeTools = modelInfo.supportsNativeTools ?? false + const useNativeTools = + supportsNativeTools && metadata?.tools && metadata.tools.length > 0 && metadata?.toolProtocol !== "xml" + // ------------------------- // Track token usage // ------------------------- @@ -87,6 +94,9 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan messages: openAiMessages, temperature: this.options.modelTemperature ?? LMSTUDIO_DEFAULT_TEMPERATURE, stream: true, + ...(useNativeTools && { tools: this.convertToolsForOpenAI(metadata.tools) }), + ...(useNativeTools && metadata.tool_choice && { tool_choice: metadata.tool_choice }), + ...(useNativeTools && { parallel_tool_calls: metadata?.parallelToolCalls ?? false }), } if (this.options.lmStudioSpeculativeDecodingEnabled && this.options.lmStudioDraftModelId) { @@ -111,6 +121,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan for await (const chunk of results) { const delta = chunk.choices[0]?.delta + const finishReason = chunk.choices[0]?.finish_reason if (delta?.content) { assistantText += delta.content @@ -118,6 +129,27 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan yield processedChunk } } + + // Handle tool calls in stream - emit partial chunks for NativeToolCallParser + 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, + } + } + } + + // Process finish_reason to emit tool_call_end events + if (finishReason) { + const endEvents = NativeToolCallParser.processFinishReason(finishReason) + for (const event of endEvents) { + yield event + } + } } for (const processedChunk of matcher.final()) { diff --git a/src/api/providers/qwen-code.ts b/src/api/providers/qwen-code.ts index d930d9dfc7b..8f26273ebaf 100644 --- a/src/api/providers/qwen-code.ts +++ b/src/api/providers/qwen-code.ts @@ -8,11 +8,13 @@ import { type ModelInfo, type QwenCodeModelId, qwenCodeModels, qwenCodeDefaultMo import type { ApiHandlerOptions } from "../../shared/api" +import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser" + 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,11 +203,20 @@ 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() + // Check if model supports native tools and tools are provided with native protocol + const supportsNativeTools = model.info.supportsNativeTools ?? false + const useNativeTools = + supportsNativeTools && metadata?.tools && metadata.tools.length > 0 && metadata?.toolProtocol !== "xml" + const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = { role: "system", content: systemPrompt, @@ -220,6 +231,9 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan stream: true, stream_options: { include_usage: true }, max_completion_tokens: model.info.maxTokens, + ...(useNativeTools && { tools: this.convertToolsForOpenAI(metadata.tools) }), + ...(useNativeTools && metadata.tool_choice && { tool_choice: metadata.tool_choice }), + ...(useNativeTools && { parallel_tool_calls: metadata?.parallelToolCalls ?? false }), } const stream = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions)) @@ -228,6 +242,7 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan for await (const apiChunk of stream) { const delta = apiChunk.choices[0]?.delta ?? {} + const finishReason = apiChunk.choices[0]?.finish_reason if (delta.content) { let newText = delta.content @@ -274,6 +289,27 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan } } + // Handle tool calls in stream - emit partial chunks for NativeToolCallParser + 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, + } + } + } + + // Process finish_reason to emit tool_call_end events + if (finishReason) { + const endEvents = NativeToolCallParser.processFinishReason(finishReason) + for (const event of endEvents) { + yield event + } + } + if (apiChunk.usage) { yield { type: "usage", From 714bf941f5df1c46674afdb2e18ae68b07c0e289 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 19 Dec 2025 10:21:30 -0700 Subject: [PATCH 2/4] fix(lm-studio): add supportsNativeTools and defaultToolProtocol to default model info Without this, native tool calling would never activate for LM Studio models because parseLMStudioModel() merges from lMStudioDefaultModelInfo which didn't have supportsNativeTools set. The check in lm-studio.ts: const supportsNativeTools = modelInfo.supportsNativeTools ?? false would always default to false, making the native tools implementation dead code. --- packages/types/src/providers/lm-studio.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/types/src/providers/lm-studio.ts b/packages/types/src/providers/lm-studio.ts index d0df1344702..a5a1202c2e0 100644 --- a/packages/types/src/providers/lm-studio.ts +++ b/packages/types/src/providers/lm-studio.ts @@ -10,6 +10,8 @@ export const lMStudioDefaultModelInfo: ModelInfo = { contextWindow: 200_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, cacheWritesPrice: 0, From ef31007643c6d15a5282900a587ad5b6a0607adc Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 19 Dec 2025 10:33:10 -0700 Subject: [PATCH 3/4] fix(lm-studio): always enable native tools support Per mrubens' review feedback, LM Studio always supports native tools (https://lmstudio.ai/docs/developer/core/tools), so we shouldn't gate this on modelInfo.supportsNativeTools which may not be set in the model cache. --- src/api/providers/lm-studio.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index 26ea3636124..102c108dcee 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -47,11 +47,8 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan ...convertToOpenAiMessages(messages), ] - // Check if model supports native tools and tools are provided with native protocol - const modelInfo = this.getModel().info - const supportsNativeTools = modelInfo.supportsNativeTools ?? false - const useNativeTools = - supportsNativeTools && metadata?.tools && metadata.tools.length > 0 && metadata?.toolProtocol !== "xml" + // LM Studio always supports native tools (https://lmstudio.ai/docs/developer/core/tools) + const useNativeTools = metadata?.tools && metadata.tools.length > 0 && metadata?.toolProtocol !== "xml" // ------------------------- // Track token usage From 65b4865385d072581c1b797212da6c5a0ab1f30d Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 19 Dec 2025 10:37:31 -0700 Subject: [PATCH 4/4] fix(lm-studio): merge native tool defaults in useSelectedModel Similar to the LiteLLM fix in PR #10187, this ensures that the Tool Protocol dropdown appears in the UI for LM Studio users by merging supportsNativeTools and defaultToolProtocol from lMStudioDefaultModelInfo with the dynamically fetched model info. --- .../src/components/ui/hooks/useSelectedModel.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index f405dd78930..eaf58530e97 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -29,6 +29,7 @@ import { basetenModels, qwenCodeModels, litellmDefaultModelInfo, + lMStudioDefaultModelInfo, BEDROCK_1M_CONTEXT_MODEL_IDS, isDynamicProvider, getProviderDefaultModelId, @@ -300,10 +301,16 @@ function getSelectedModel({ } case "lmstudio": { const id = apiConfiguration.lmStudioModelId ?? "" - const info = lmStudioModels && lmStudioModels[apiConfiguration.lmStudioModelId!] + const modelInfo = lmStudioModels && lmStudioModels[apiConfiguration.lmStudioModelId!] + // Only merge native tool call defaults, not prices or other model-specific info + const nativeToolDefaults = { + supportsNativeTools: lMStudioDefaultModelInfo.supportsNativeTools, + defaultToolProtocol: lMStudioDefaultModelInfo.defaultToolProtocol, + } + const info = modelInfo ? { ...nativeToolDefaults, ...modelInfo } : undefined return { id, - info: info || undefined, + info, } } case "deepinfra": {