diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts index fa7c19c5ed..60d4cae831 100644 --- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts +++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts @@ -236,6 +236,25 @@ describe("BaseOpenAiCompatibleProvider", () => { // Should yield reasoning with spaces (only pure whitespace is filtered) expect(chunks).toEqual([{ type: "reasoning", text: " content with spaces " }]) }) + + it("should yield reasoning chunks BEFORE text chunks when both are present in the exact same delta", async () => { + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { + choices: [{ delta: { reasoning_content: "thinking...", content: "answer" } }], + }, + ]), + ) + + const stream = handler.createMessage("system prompt", []) + const chunks = await collectStream(stream) + + const contentChunks = chunks.filter((c) => c.type === "reasoning" || c.type === "text") + expect(contentChunks).toEqual([ + { type: "reasoning", text: "thinking..." }, + { type: "text", text: "answer" }, + ]) + }) }) describe("Basic functionality", () => { diff --git a/src/api/providers/__tests__/lite-llm.spec.ts b/src/api/providers/__tests__/lite-llm.spec.ts index 20bd3b1be2..6cae37afe7 100644 --- a/src/api/providers/__tests__/lite-llm.spec.ts +++ b/src/api/providers/__tests__/lite-llm.spec.ts @@ -731,6 +731,31 @@ describe("LiteLLMHandler", () => { expect(textChunk).toMatchObject({ type: "text", text: "The answer is 42." }) }) + it("should yield reasoning chunks BEFORE text chunks when both are present in the exact same delta", async () => { + const mockStream = asyncStreamFrom([ + { + choices: [{ delta: { reasoning_content: "thinking...", content: "answer" } }], + usage: { prompt_tokens: 10, completion_tokens: 10 }, + }, + ]) + + mockCreate.mockReturnValue({ + withResponse: vi.fn().mockResolvedValue({ data: mockStream }), + }) + + const generator = handler.createMessage("system", [{ role: "user", content: "Test simultaneous." }]) + const results = await collectStream(generator) + + // Filter out usage chunks to focus on ordering of content + const contentResults = results.filter((r) => r.type === "reasoning" || r.type === "text") + + // The order is strictly enforced here + expect(contentResults).toEqual([ + { type: "reasoning", text: "thinking..." }, + { type: "text", text: "answer" }, + ]) + }) + it("should yield reasoning chunks from reasoning delta field", async () => { const mockStream = asyncStreamFrom([ { diff --git a/src/api/providers/__tests__/nanogpt.spec.ts b/src/api/providers/__tests__/nanogpt.spec.ts index 0ddb4f5d5d..e945165732 100644 --- a/src/api/providers/__tests__/nanogpt.spec.ts +++ b/src/api/providers/__tests__/nanogpt.spec.ts @@ -123,8 +123,8 @@ describe("NanoGptHandler", () => { new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages), ) expect(chunks).toEqual([ - { type: "text", text: "answer" }, { type: "reasoning", text: "modern" }, + { type: "text", text: "answer" }, { type: "reasoning", text: "legacy" }, { type: "tool_call_partial", index: 0, id: "call-1", name: "read_file", arguments: '{"path":' }, { type: "tool_call_partial", index: 1, id: "call-2", name: "search_files", arguments: '{"query":' }, diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 38550533a5..327376de71 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -658,6 +658,26 @@ describe("OpenAiHandler", () => { expect(callArgs.max_completion_tokens).toBe(4096) }) + it("should yield reasoning chunks BEFORE text chunks when both are present in the exact same delta", async () => { + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { + choices: [{ delta: { reasoning_content: "thinking...", content: "answer" } }], + usage: { prompt_tokens: 10, completion_tokens: 10 }, + }, + ]), + ) + + const stream = handler.createMessage("system prompt", []) + const chunks = await collectStream(stream) + + const contentChunks = chunks.filter((c) => c.type === "reasoning" || c.type === "text") + expect(contentChunks).toEqual([ + { type: "reasoning", text: "thinking..." }, + { type: "text", text: "answer" }, + ]) + }) + describe("TagMatcher reasoning tags", () => { it("should treat stray closing tag as plain text when no tag is open", async () => { mockCreate.mockImplementationOnce(() => diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index f4928b0b0a..1cf80784d0 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -141,17 +141,17 @@ export abstract class BaseOpenAiCompatibleProvider const delta = chunk.choices?.[0]?.delta const finishReason = chunk.choices?.[0]?.finish_reason + const reasoningText = extractReasoningFromDelta(delta) + if (reasoningText) { + yield { type: "reasoning", text: reasoningText } + } + if (delta?.content) { for (const processedChunk of matcher.update(delta.content)) { yield processedChunk } } - const reasoningText = extractReasoningFromDelta(delta) - if (reasoningText) { - yield { type: "reasoning", text: reasoningText } - } - // Emit raw tool call chunks - NativeToolCallParser handles state management if (delta?.tool_calls) { for (const toolCall of delta.tool_calls) { diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 64782c9fb4..37fcce62b8 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -165,6 +165,13 @@ export class DeepSeekHandler extends OpenAiHandler { for await (const chunk of stream) { const delta = chunk.choices?.[0]?.delta ?? {} + // Handle reasoning_content from DeepSeek's interleaved thinking + // This is the proper way DeepSeek sends thinking content in streaming + const reasoningText = extractReasoningFromDelta(delta) + if (reasoningText) { + yield { type: "reasoning", text: reasoningText } + } + // Handle regular text content if (delta.content) { yield { @@ -173,13 +180,6 @@ export class DeepSeekHandler extends OpenAiHandler { } } - // Handle reasoning_content from DeepSeek's interleaved thinking - // This is the proper way DeepSeek sends thinking content in streaming - const reasoningText = extractReasoningFromDelta(delta) - if (reasoningText) { - yield { type: "reasoning", text: reasoningText } - } - // Handle tool calls if (delta.tool_calls) { for (const toolCall of delta.tool_calls) { diff --git a/src/api/providers/kenari.ts b/src/api/providers/kenari.ts index a6ad643ec3..528c1c9694 100644 --- a/src/api/providers/kenari.ts +++ b/src/api/providers/kenari.ts @@ -82,10 +82,6 @@ export class KenariHandler extends RouterProvider implements SingleCompletionHan for await (const chunk of completion) { const delta = chunk.choices[0]?.delta - if (delta?.content) { - yield { type: "text", text: delta.content } - } - // Several Kenari models (GLM, DeepSeek) stream reasoning via reasoning_content, // with an OpenRouter-style `reasoning` fallback; the shared helper handles both. const reasoningText = extractReasoningFromDelta(delta) @@ -93,6 +89,10 @@ export class KenariHandler extends RouterProvider implements SingleCompletionHan yield { type: "reasoning", text: reasoningText } } + if (delta?.content) { + yield { type: "text", text: delta.content } + } + // Emit raw tool call chunks - NativeToolCallParser handles state management. if (delta?.tool_calls) { for (const toolCall of delta.tool_calls) { diff --git a/src/api/providers/lite-llm.ts b/src/api/providers/lite-llm.ts index 8cfe2d0a19..b2989127c9 100644 --- a/src/api/providers/lite-llm.ts +++ b/src/api/providers/lite-llm.ts @@ -257,15 +257,15 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa const delta = chunk.choices[0]?.delta const usage = chunk.usage as LiteLLMUsage - if (delta?.content) { - yield { type: "text", text: delta.content } - } - const reasoningText = extractReasoningFromDelta(delta) if (reasoningText) { yield { type: "reasoning", text: reasoningText } } + if (delta?.content) { + yield { type: "text", text: delta.content } + } + // Handle tool calls in stream - emit partial chunks for NativeToolCallParser if (delta?.tool_calls) { for (const toolCall of delta.tool_calls) { diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index 0c828984bc..59f484829c 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -123,13 +123,6 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan const delta = chunk.choices[0]?.delta const finishReason = chunk.choices[0]?.finish_reason - if (delta?.content) { - assistantText += delta.content - for (const processedChunk of matcher.update(delta.content)) { - yield processedChunk - } - } - // 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. @@ -139,6 +132,13 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan yield { type: "reasoning", text: reasoningText } } + if (delta?.content) { + assistantText += delta.content + for (const processedChunk of matcher.update(delta.content)) { + yield processedChunk + } + } + // Handle tool calls in stream - emit partial chunks for NativeToolCallParser if (delta?.tool_calls) { for (const toolCall of delta.tool_calls) { diff --git a/src/api/providers/mimo.ts b/src/api/providers/mimo.ts index e3a794afae..d2fba1cb48 100644 --- a/src/api/providers/mimo.ts +++ b/src/api/providers/mimo.ts @@ -122,6 +122,11 @@ export class MimoHandler extends OpenAiHandler { } : delta + const reasoningText = extractReasoningFromDelta(delta) + if (reasoningText) { + yield { type: "reasoning", text: reasoningText } + } + if (delta.content) { yield { type: "text", @@ -129,11 +134,6 @@ export class MimoHandler extends OpenAiHandler { } } - const reasoningText = extractReasoningFromDelta(delta) - if (reasoningText) { - yield { type: "reasoning", text: reasoningText } - } - yield* this.processToolCalls(sanitizedDelta, finishReason, activeToolCallIds) if (chunk.usage) { diff --git a/src/api/providers/nanogpt.ts b/src/api/providers/nanogpt.ts index 7b0c7930d3..30100711a3 100644 --- a/src/api/providers/nanogpt.ts +++ b/src/api/providers/nanogpt.ts @@ -123,15 +123,15 @@ export class NanoGptHandler extends RouterProvider implements SingleCompletionHa const completion = await this.client.chat.completions.create(body, { signal: metadata?.abortSignal }) for await (const chunk of completion) { const delta = chunk.choices[0]?.delta - if (delta?.content) { - yield { type: "text", text: delta.content } - } - const reasoning = extractReasoningFromDelta(delta) if (reasoning) { yield { type: "reasoning", text: reasoning } } + if (delta?.content) { + yield { type: "text", text: delta.content } + } + for (const toolCall of delta?.tool_calls ?? []) { yield { type: "tool_call_partial", diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 5588dd37d6..de1d5ae57e 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -201,17 +201,17 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const delta = chunk.choices?.[0]?.delta ?? {} const finishReason = chunk.choices?.[0]?.finish_reason + const reasoningText = extractReasoningFromDelta(delta) + if (reasoningText) { + yield { type: "reasoning", text: reasoningText } + } + if (delta.content) { for (const chunk of matcher.update(delta.content)) { yield chunk } } - const reasoningText = extractReasoningFromDelta(delta) - if (reasoningText) { - yield { type: "reasoning", text: reasoningText } - } - yield* this.processToolCalls(delta, finishReason, activeToolCallIds) if (chunk.usage) { diff --git a/src/api/providers/opencode-go.ts b/src/api/providers/opencode-go.ts index ba1c87e223..1f4faa45d6 100644 --- a/src/api/providers/opencode-go.ts +++ b/src/api/providers/opencode-go.ts @@ -252,16 +252,16 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio for await (const chunk of completion) { const delta = chunk.choices[0]?.delta - if (delta?.content) { - yield { type: "text", text: delta.content } - } - // Several Go-plan models (GLM, DeepSeek) stream reasoning via this field. const reasoningText = extractReasoningFromDelta(delta) if (reasoningText) { yield { type: "reasoning", text: reasoningText } } + if (delta?.content) { + yield { type: "text", text: delta.content } + } + // Emit raw tool call chunks - NativeToolCallParser handles state management. if (delta?.tool_calls) { for (const toolCall of delta.tool_calls) { diff --git a/src/api/providers/qwen-code.ts b/src/api/providers/qwen-code.ts index 5001b4c8ed..7d98bcb77d 100644 --- a/src/api/providers/qwen-code.ts +++ b/src/api/providers/qwen-code.ts @@ -247,6 +247,11 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan const delta = apiChunk.choices[0]?.delta ?? {} const finishReason = apiChunk.choices[0]?.finish_reason + const reasoningText = extractReasoningFromDelta(delta) + if (reasoningText) { + yield { type: "reasoning", text: reasoningText } + } + if (delta.content) { let newText = delta.content if (newText.startsWith(fullContent)) { @@ -285,11 +290,6 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan } } - const reasoningText = extractReasoningFromDelta(delta) - if (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) { diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 1ba0771ce2..2c1092d303 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -178,15 +178,15 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan for await (const chunk of stream) { const delta = chunk.choices[0]?.delta - if (delta?.content) { - yield { type: "text", text: delta.content } - } - const reasoningText = extractReasoningFromDelta(delta) if (reasoningText) { yield { type: "reasoning", text: reasoningText } } + if (delta?.content) { + yield { type: "text", text: delta.content } + } + // Handle native tool calls if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) { for (const toolCall of delta.tool_calls) { diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index 0848e0804b..61a2d1ae38 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -169,15 +169,15 @@ export class UnboundHandler extends BaseProvider implements SingleCompletionHand for await (const chunk of stream) { const delta = chunk.choices[0]?.delta - if (delta?.content) { - yield { type: "text", text: delta.content } - } - const reasoningText = extractReasoningFromDelta(delta) if (reasoningText) { yield { type: "reasoning", text: reasoningText } } + if (delta?.content) { + yield { type: "text", text: delta.content } + } + // Handle native tool calls if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) { for (const toolCall of delta.tool_calls) {