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
9 changes: 4 additions & 5 deletions src/api/providers/cerebras.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { ApiHandlerOptions } from "../../shared/api"
import { calculateApiCostOpenAI } from "../../shared/cost"
import { ApiStream } from "../transform/stream"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { XmlMatcher } from "../../utils/xml-matcher"
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"

import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index"
import { BaseProvider } from "./base-provider"
Expand Down Expand Up @@ -187,9 +187,8 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan
throw new Error(t("common:errors.cerebras.noResponseBody"))
}

// Initialize XmlMatcher to parse <think>...</think> tags
const matcher = new XmlMatcher(
"think",
// Initialize ReasoningXmlMatcher to parse reasoning tags
const matcher = new ReasoningXmlMatcher(
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
Expand Down Expand Up @@ -228,7 +227,7 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan
if (parsed.choices?.[0]?.delta?.content) {
const content = parsed.choices[0].delta.content

// Use XmlMatcher to parse <think>...</think> tags
// Use ReasoningXmlMatcher to parse reasoning tags
for (const chunk of matcher.update(content)) {
yield chunk
}
Expand Down
5 changes: 2 additions & 3 deletions src/api/providers/chutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"

import type { ApiHandlerOptions } from "../../shared/api"
import { XmlMatcher } from "../../utils/xml-matcher"
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"
import { convertToR1Format } from "../transform/r1-format"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
Expand Down Expand Up @@ -53,8 +53,7 @@ export class ChutesHandler extends BaseOpenAiCompatibleProvider<ChutesModelId> {
messages: convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]),
})

const matcher = new XmlMatcher(
"think",
const matcher = new ReasoningXmlMatcher(
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
Expand Down
12 changes: 8 additions & 4 deletions src/api/providers/featherless.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { DEEP_SEEK_DEFAULT_TEMPERATURE, type FeatherlessModelId, featherlessDefaultModelId, featherlessModels } from "@roo-code/types"
import {
DEEP_SEEK_DEFAULT_TEMPERATURE,
type FeatherlessModelId,
featherlessDefaultModelId,
featherlessModels,
} from "@roo-code/types"
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"

import type { ApiHandlerOptions } from "../../shared/api"
import { XmlMatcher } from "../../utils/xml-matcher"
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"
import { convertToR1Format } from "../transform/r1-format"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
Expand Down Expand Up @@ -53,8 +58,7 @@ export class FeatherlessHandler extends BaseOpenAiCompatibleProvider<Featherless
messages: convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]),
})

const matcher = new XmlMatcher(
"think",
const matcher = new ReasoningXmlMatcher(
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
Expand Down
5 changes: 2 additions & 3 deletions src/api/providers/lm-studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { type ModelInfo, openAiModelInfoSaneDefaults, LMSTUDIO_DEFAULT_TEMPERATU

import type { ApiHandlerOptions } from "../../shared/api"

import { XmlMatcher } from "../../utils/xml-matcher"
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"

import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
Expand Down Expand Up @@ -100,8 +100,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
throw handleOpenAIError(error, this.providerName)
}

const matcher = new XmlMatcher(
"think",
const matcher = new ReasoningXmlMatcher(
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
Expand Down
5 changes: 2 additions & 3 deletions src/api/providers/native-ollama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ApiStream } from "../transform/stream"
import { BaseProvider } from "./base-provider"
import type { ApiHandlerOptions } from "../../shared/api"
import { getOllamaModels } from "./fetchers/ollama"
import { XmlMatcher } from "../../utils/xml-matcher"
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"

interface OllamaChatOptions {
Expand Down Expand Up @@ -179,8 +179,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
...convertToOllamaMessages(messages),
]

const matcher = new XmlMatcher(
"think",
const matcher = new ReasoningXmlMatcher(
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
Expand Down
5 changes: 2 additions & 3 deletions src/api/providers/ollama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { type ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERAT

import type { ApiHandlerOptions } from "../../shared/api"

import { XmlMatcher } from "../../utils/xml-matcher"
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"

import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToR1Format } from "../transform/r1-format"
Expand Down Expand Up @@ -68,8 +68,7 @@ export class OllamaHandler extends BaseProvider implements SingleCompletionHandl
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}
const matcher = new XmlMatcher(
"think",
const matcher = new ReasoningXmlMatcher(
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
Expand Down
5 changes: 2 additions & 3 deletions src/api/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {

import type { ApiHandlerOptions } from "../../shared/api"

import { XmlMatcher } from "../../utils/xml-matcher"
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"

import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToR1Format } from "../transform/r1-format"
Expand Down Expand Up @@ -179,8 +179,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
throw handleOpenAIError(error, this.providerName)
}

const matcher = new XmlMatcher(
"think",
const matcher = new ReasoningXmlMatcher(
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
Expand Down
33 changes: 21 additions & 12 deletions src/core/assistant-message/presentAssistantMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,21 +93,24 @@ export async function presentAssistantMessage(cline: Task) {

if (content) {
// Have to do this for partial and complete since sending
// content in thinking tags to markdown renderer will
// content in reasoning tags to markdown renderer will
// automatically be removed.
// Remove end substrings of <thinking or </thinking (below xml
// parsing is only for opening tags).
// Tthis is done with the xml parsing below now, but keeping
// here for reference.
// content = content.replace(/<\/?t(?:h(?:i(?:n(?:k(?:i(?:n(?:g)?)?)?$/, "")
//
// Remove all instances of <thinking> (with optional line break
// after) and </thinking> (with optional line break before).
// Remove all instances of reasoning tags: <think>, <thinking>, <reasoning>, <thought>
// (with optional line break after opening tags) and their closing tags
// (with optional line break before closing tags).
// - Needs to be separate since we dont want to remove the line
// break before the first tag.
// - Needs to happen before the xml parsing below.
content = content.replace(/<thinking>\s?/g, "")
content = content.replace(/\s?<\/thinking>/g, "")
const reasoningTags = ["think", "thinking", "reasoning", "thought"]
reasoningTags.forEach((tag) => {
// Remove opening tags with optional line break after
const openingRegex = new RegExp(`<${tag}>\\s?`, "g")
content = content.replace(openingRegex, "")
// Remove closing tags with optional line break before
const closingRegex = new RegExp(`\\s?<\\/${tag}>`, "g")
content = content.replace(closingRegex, "")
})

// Remove partial XML tag at the very end of the content (for
// tool use and thinking tags), Prevents scrollview from
Expand Down Expand Up @@ -136,14 +139,20 @@ export async function presentAssistantMessage(cline: Task) {
// (letters and underscores only).
const isLikelyTagName = /^[a-zA-Z_]+$/.test(tagContent)

// Check if it's a partial reasoning tag
const reasoningTags = ["think", "thinking", "reasoning", "thought"]
const isPartialReasoningTag = reasoningTags.some(
(tag) => tag.startsWith(tagContent) || tagContent.startsWith(tag),
)

// Preemptively remove < or </ to keep from these
// artifacts showing up in chat (also handles closing
// thinking tags).
// reasoning tags).
const isOpeningOrClosing = possibleTag === "<" || possibleTag === "</"

// If the tag is incomplete and at the end, remove it
// from the content.
if (isOpeningOrClosing || isLikelyTagName) {
if (isOpeningOrClosing || isLikelyTagName || isPartialReasoningTag) {
content = content.slice(0, lastOpenBracketIndex).trim()
}
}
Expand Down
131 changes: 131 additions & 0 deletions src/utils/__tests__/reasoning-xml-matcher.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, it, expect } from "vitest"
import { ReasoningXmlMatcher } from "../reasoning-xml-matcher"

describe("ReasoningXmlMatcher", () => {
it("should match <think> tags", () => {
const matcher = new ReasoningXmlMatcher()
const input = "Some text <think>This is reasoning content</think> more text"
const results = matcher.final(input)

expect(results).toHaveLength(3)
expect(results[0]).toEqual({ matched: false, data: "Some text " })
expect(results[1]).toEqual({ matched: true, data: "<think>This is reasoning content</think>" })
expect(results[2]).toEqual({ matched: false, data: " more text" })
})

it("should match <thinking> tags", () => {
const matcher = new ReasoningXmlMatcher()
const input = "Some text <thinking>This is reasoning content</thinking> more text"
const results = matcher.final(input)

expect(results).toHaveLength(3)
expect(results[0]).toEqual({ matched: false, data: "Some text " })
expect(results[1]).toEqual({ matched: true, data: "<thinking>This is reasoning content</thinking>" })
expect(results[2]).toEqual({ matched: false, data: " more text" })
})

it("should match <reasoning> tags", () => {
const matcher = new ReasoningXmlMatcher()
const input = "Some text <reasoning>This is reasoning content</reasoning> more text"
const results = matcher.final(input)

expect(results).toHaveLength(3)
expect(results[0]).toEqual({ matched: false, data: "Some text " })
expect(results[1]).toEqual({ matched: true, data: "<reasoning>This is reasoning content</reasoning>" })
expect(results[2]).toEqual({ matched: false, data: " more text" })
})

it("should match <thought> tags", () => {
const matcher = new ReasoningXmlMatcher()
const input = "Some text <thought>This is reasoning content</thought> more text"
const results = matcher.final(input)

expect(results).toHaveLength(3)
expect(results[0]).toEqual({ matched: false, data: "Some text " })
expect(results[1]).toEqual({ matched: true, data: "<thought>This is reasoning content</thought>" })
expect(results[2]).toEqual({ matched: false, data: " more text" })
})

it("should handle streaming updates for all tag variants", () => {
const testCases = [
{ tag: "think", content: "Thinking about the problem" },
{ tag: "thinking", content: "Processing the request" },
{ tag: "reasoning", content: "Analyzing the situation" },
{ tag: "thought", content: "Considering options" },
]

testCases.forEach(({ tag, content }) => {
const matcher = new ReasoningXmlMatcher()

// Simulate streaming
const chunks = [
"Initial text ",
`<${tag}>`,
content.slice(0, 10),
content.slice(10),
`</${tag}>`,
" final text",
]

let allResults: any[] = []
chunks.forEach((chunk) => {
const results = matcher.update(chunk)
allResults.push(...results)
})

// Get final results
const finalResults = matcher.final()
allResults.push(...finalResults)

// Verify we got the expected matched content
const matchedResults = allResults.filter((r) => r.matched)
const unmatchedResults = allResults.filter((r) => !r.matched)

expect(matchedResults.length).toBeGreaterThan(0)
const fullMatchedContent = matchedResults.map((r) => r.data).join("")
expect(fullMatchedContent).toContain(content)

const fullUnmatchedContent = unmatchedResults.map((r) => r.data).join("")
expect(fullUnmatchedContent).toContain("Initial text")
expect(fullUnmatchedContent).toContain("final text")
})
})

it("should handle nested tags correctly", () => {
const matcher = new ReasoningXmlMatcher()
const input = "<think>Outer <think>Inner</think> content</think>"
const results = matcher.final(input)

// Should match the entire nested structure
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
matched: true,
data: "<think>Outer <think>Inner</think> content</think>",
})
})

it("should handle multiple different reasoning tags in sequence", () => {
const matcher = new ReasoningXmlMatcher()
const input = "Text <think>Think content</think> middle <thinking>Thinking content</thinking> end"
const results = matcher.final(input)

// Should match only the first tag type encountered
expect(results.filter((r) => r.matched).length).toBeGreaterThan(0)
expect(results.some((r) => r.data.includes("Think content"))).toBe(true)
})

it("should apply custom transform function", () => {
const transform = (chunk: { matched: boolean; data: string }) => ({
type: chunk.matched ? "reasoning" : "text",
text: chunk.data,
})

const matcher = new ReasoningXmlMatcher(transform)
const input = "Normal text <think>Reasoning here</think> more text"
const results = matcher.final(input)

expect(results[0]).toEqual({ type: "text", text: "Normal text " })
expect(results[1]).toEqual({ type: "reasoning", text: "<think>Reasoning here</think>" })
expect(results[2]).toEqual({ type: "text", text: " more text" })
})
})
Loading
Loading