Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
25 changes: 25 additions & 0 deletions src/api/providers/__tests__/lite-llm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand Down
2 changes: 1 addition & 1 deletion src/api/providers/__tests__/nanogpt.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":' },
Expand Down
20 changes: 20 additions & 0 deletions src/api/providers/__tests__/openai.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() =>
Expand Down
10 changes: 5 additions & 5 deletions src/api/providers/base-openai-compatible-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,17 +141,17 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
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) {
Expand Down
14 changes: 7 additions & 7 deletions src/api/providers/deepseek.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions src/api/providers/kenari.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,17 @@ 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)
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) {
Expand Down
8 changes: 4 additions & 4 deletions src/api/providers/lite-llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
14 changes: 7 additions & 7 deletions src/api/providers/lm-studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <think> tags inside `content`, so TagMatcher never sees it.
Expand All @@ -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) {
Expand Down
10 changes: 5 additions & 5 deletions src/api/providers/mimo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,18 +122,18 @@ export class MimoHandler extends OpenAiHandler {
}
: delta

const reasoningText = extractReasoningFromDelta(delta)
if (reasoningText) {
yield { type: "reasoning", text: reasoningText }
}

if (delta.content) {
yield {
type: "text",
text: delta.content,
}
}

const reasoningText = extractReasoningFromDelta(delta)
if (reasoningText) {
yield { type: "reasoning", text: reasoningText }
}

yield* this.processToolCalls(sanitizedDelta, finishReason, activeToolCallIds)

if (chunk.usage) {
Expand Down
8 changes: 4 additions & 4 deletions src/api/providers/nanogpt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions src/api/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions src/api/providers/opencode-go.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
10 changes: 5 additions & 5 deletions src/api/providers/qwen-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions src/api/providers/requesty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions src/api/providers/unbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading