diff --git a/src/api/providers/__tests__/lmstudio.spec.ts b/src/api/providers/__tests__/lmstudio.spec.ts index 0fde087957..8c3ded7eef 100644 --- a/src/api/providers/__tests__/lmstudio.spec.ts +++ b/src/api/providers/__tests__/lmstudio.spec.ts @@ -112,6 +112,59 @@ describe("LmStudioHandler", () => { expect(textChunks[0].text).toBe("Test response") }) + it("streams reasoning chunks from delta.reasoning_content", async () => { + // Regression: Qwen3 / DeepSeek-R1 style models served by LM Studio emit + // thinking via reasoning_content, not tags inside content. + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [{ delta: { reasoning_content: "thinking..." }, index: 0 }] }, + { choices: [{ delta: { content: "answer" }, index: 0 }] }, + { + choices: [{ delta: {}, index: 0 }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ]), + ) + + const chunks = await collectStream(handler.createMessage(systemPrompt, messages)) + + expect(chunks).toContainEqual({ type: "reasoning", text: "thinking..." }) + expect(chunks).toContainEqual({ type: "text", text: "answer" }) + }) + + it("falls back to delta.reasoning when reasoning_content is absent", async () => { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [{ delta: { reasoning: "router-style thought" }, index: 0 }] }, + { + choices: [{ delta: {}, index: 0 }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ]), + ) + + const chunks = await collectStream(handler.createMessage(systemPrompt, messages)) + + expect(chunks).toContainEqual({ type: "reasoning", text: "router-style thought" }) + }) + + it("still parses tags embedded in content", async () => { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [{ delta: { content: "tagged thoughtvisible" }, index: 0 }] }, + { + choices: [{ delta: {}, index: 0 }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ]), + ) + + const chunks = await collectStream(handler.createMessage(systemPrompt, messages)) + + expect(chunks).toContainEqual({ type: "reasoning", text: "tagged thought" }) + expect(chunks).toContainEqual({ type: "text", text: "visible" }) + }) + it("should handle API errors", async () => { mockCreate.mockRejectedValueOnce(new Error("API Error")) diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index 79f5355ef8..b1b3106b54 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -16,6 +16,7 @@ import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { getModelsFromCache } from "./fetchers/modelCache" import { handleOpenAIError } from "./utils/error-handler" +import { extractReasoningFromDelta } from "./utils/extract-reasoning" export class LmStudioHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions @@ -80,6 +81,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan } let assistantText = "" + let reasoningOutput = "" try { const params: OpenAI.Chat.ChatCompletionCreateParamsStreaming & { draft_model?: string } = { @@ -123,6 +125,15 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan } } + // Reasoning models served by LM Studio (Qwen3, DeepSeek-R1, QwQ, ...) stream + // their thinking in a dedicated `reasoning_content`/`reasoning` delta field + // rather than as tags inside `content`, so TagMatcher never sees it. + const reasoningText = extractReasoningFromDelta(delta) + if (reasoningText) { + reasoningOutput += reasoningText + yield { type: "reasoning", text: reasoningText } + } + // Handle tool calls in stream - emit partial chunks for NativeToolCallParser if (delta?.tool_calls) { for (const toolCall of delta.tool_calls) { @@ -151,7 +162,9 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan let outputTokens = 0 try { - outputTokens = await this.countTokens([{ type: "text", text: assistantText }]) + // Reasoning tokens are billed as output, so count them alongside the + // visible text — otherwise thinking models under-report usage entirely. + outputTokens = await this.countTokens([{ type: "text", text: reasoningOutput + assistantText }]) } catch (err) { console.error("[LmStudio] Failed to count output tokens:", err) outputTokens = 0