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 6f8d121e69..a775abebd2 100644
--- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts
+++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts
@@ -95,6 +95,75 @@ describe("BaseOpenAiCompatibleProvider", () => {
])
})
+ it("should handle reasoning tags () from stream", async () => {
+ mockCreate.mockImplementationOnce(() => {
+ return {
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "Deep thought" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " here" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "Result: 42" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }
+ })
+ const stream = handler.createMessage("system prompt", [])
+ const chunks = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+ expect(chunks).toEqual([
+ { type: "reasoning", text: "Deep thought" },
+ { type: "reasoning", text: " here" },
+ { type: "text", text: "Result: 42" },
+ ])
+ })
+
+ it("should not close tag with tag", async () => {
+ mockCreate.mockImplementationOnce(() => {
+ return {
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "Thinking" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " but closing with wrong tag" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " still thinking" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }
+ })
+ const stream = handler.createMessage("system prompt", [])
+ const chunks = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+ // The tag should be treated as text since it doesn't match the active tag
+ expect(chunks).toEqual([
+ { type: "reasoning", text: "Thinking" },
+ { type: "reasoning", text: " but closing with wrong tag" },
+ { type: "reasoning", text: " still thinking" },
+ ])
+ })
+
it("should handle complete tag in a single chunk", async () => {
mockCreate.mockImplementationOnce(() => {
return {
diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts
index ccbbb870df..03dc9e6886 100644
--- a/src/api/providers/__tests__/openai.spec.ts
+++ b/src/api/providers/__tests__/openai.spec.ts
@@ -544,6 +544,409 @@ describe("OpenAiHandler", () => {
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.max_completion_tokens).toBe(4096)
})
+
+ describe("TagMatcher reasoning tags", () => {
+ it("should handle tags from stream", async () => {
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "Let me think" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " about this" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "The answer is 42" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }))
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ expect(chunks).toEqual([
+ { type: "reasoning", text: "Let me think" },
+ { type: "reasoning", text: " about this" },
+ { type: "text", text: "The answer is 42" },
+ ])
+ })
+
+ it("should handle tags from stream", async () => {
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "Deep thought" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " here" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "Result: 42" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }))
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ expect(chunks).toEqual([
+ { type: "reasoning", text: "Deep thought" },
+ { type: "reasoning", text: " here" },
+ { type: "text", text: "Result: 42" },
+ ])
+ })
+
+ it("should not close tag with tag", async () => {
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "Thinking" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " but closing with wrong tag" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " still thinking" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "final text" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }))
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ // The tag should not match the active tag, so the closing
+ // tag is treated as text. The " still thinking" stays reasoning since
+ // was never closed with .
+ expect(chunks).toEqual([
+ { type: "reasoning", text: "Thinking" },
+ { type: "reasoning", text: " but closing with wrong tag" },
+ { type: "reasoning", text: " still thinking" },
+ { type: "text", text: "final text" },
+ ])
+ })
+
+ it("should handle text without any tags", async () => {
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "Just regular text" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " without reasoning" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }))
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ expect(chunks).toEqual([
+ { type: "text", text: "Just regular text" },
+ { type: "text", text: " without reasoning" },
+ ])
+ })
+
+ it("should treat stray closing tag as plain text when no tag is open", async () => {
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "finaltext" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }))
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ expect(chunks).toEqual([{ type: "text", text: "finaltext" }])
+ })
+
+ it("should treat extra closing tag after a closed block as plain text", async () => {
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: {
+ choices: [{ delta: { content: "thinkingfinaltext" } }],
+ },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }))
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ expect(chunks).toEqual([
+ { type: "reasoning", text: "thinking" },
+ { type: "text", text: "finaltext" },
+ ])
+ })
+
+ it("should handle tags that start at beginning of stream", async () => {
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "reasoning" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " content" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " normal text" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }))
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ expect(chunks).toEqual([
+ { type: "reasoning", text: "reasoning" },
+ { type: "reasoning", text: " content" },
+ { type: "text", text: " normal text" },
+ ])
+ })
+
+ it("should handle incomplete tag at end of stream", async () => {
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "Incomplete thought" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }))
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ // TagMatcher should flush remaining reasoning content on final()
+ expect(chunks.length).toBeGreaterThan(0)
+ expect(
+ chunks.some(
+ (c) => (c.type === "text" || c.type === "reasoning") && c.text.includes("Incomplete thought"),
+ ),
+ ).toBe(true)
+ })
+
+ it("should handle complete tag in a single chunk", async () => {
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "text before " } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "Complete thought" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " text after" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }))
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ // The TagMatcher processes the whole chunk character by character,
+ // so the complete tag is detected and yields reasoning text
+ expect(chunks.length).toBeGreaterThan(0)
+ expect(chunks[0]).toEqual({ type: "text", text: "text before " })
+ })
+
+ it("should handle nested mixed tags with correct closure matching", async () => {
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "outer" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "inner" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " middle" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "final text" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }))
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ // With the tag stack fix, closes inner tag,
+ // and correctly closes the outer tag.
+ // inner content inside is reasoning, middle is still reasoning under
+ expect(chunks).toEqual([
+ { type: "reasoning", text: "outer" },
+ { type: "reasoning", text: "inner" },
+ { type: "reasoning", text: " middle" },
+ { type: "text", text: "final text" },
+ ])
+ })
+
+ it("should handle nested tags with correct stack unwinding", async () => {
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "outer" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "inner" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " middle" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "final text" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }))
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ // With the tag stack fix, closes inner tag,
+ // and correctly closes the outer tag.
+ // inner content inside is reasoning, middle is still reasoning under
+ expect(chunks).toEqual([
+ { type: "reasoning", text: "outer" },
+ { type: "reasoning", text: "inner" },
+ { type: "reasoning", text: " middle" },
+ { type: "text", text: "final text" },
+ ])
+ })
+
+ it("should handle reasoning_content alongside tag matching", async () => {
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { reasoning_content: "native reasoning" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "tag based" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " final output" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }))
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ expect(chunks).toEqual([
+ { type: "reasoning", text: "native reasoning" },
+ { type: "reasoning", text: "tag based" },
+ { type: "text", text: " final output" },
+ ])
+ })
+ })
})
describe("error handling", () => {
diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts
index fc3d769ae2..fb2b36c20b 100644
--- a/src/api/providers/base-openai-compatible-provider.ts
+++ b/src/api/providers/base-openai-compatible-provider.ts
@@ -118,7 +118,7 @@ export abstract class BaseOpenAiCompatibleProvider
const stream = await this.createStream(systemPrompt, messages, metadata)
const matcher = new TagMatcher(
- "think",
+ ["think", "thought"],
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts
index 7ea33196f9..65a9230c73 100644
--- a/src/api/providers/openai.ts
+++ b/src/api/providers/openai.ts
@@ -184,7 +184,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
}
const matcher = new TagMatcher(
- "think",
+ ["think", "thought"],
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
diff --git a/src/utils/tag-matcher.ts b/src/utils/tag-matcher.ts
index 38d99a2904..2f30f8e592 100644
--- a/src/utils/tag-matcher.ts
+++ b/src/utils/tag-matcher.ts
@@ -17,11 +17,20 @@ export class TagMatcher {
state: "TEXT" | "TAG_OPEN" | "TAG_CLOSE" = "TEXT"
depth = 0
pointer = 0
+ private readonly tagNames: string[]
+ private activeTagNames: string[] = []
+ private inCode = false
+ private codeFence = 0
+ private tickRun = 0
+ private candidates: { name: string; index: number }[] = []
+
constructor(
- readonly tagName: string,
+ tagName: string | string[],
readonly transform?: (chunks: TagMatcherResult) => Result,
readonly position = 0,
- ) {}
+ ) {
+ this.tagNames = Array.isArray(tagName) ? tagName : [tagName]
+ }
private collect() {
if (!this.cached.length) {
return
@@ -57,38 +66,61 @@ export class TagMatcher {
if (char === "<" && (this.pointer <= this.position + 1 || this.matched)) {
this.state = "TAG_OPEN"
this.index = 0
+ if (this.depth === 0) {
+ this.candidates = this.tagNames.map((name) => ({ name, index: 0 }))
+ } else {
+ const active = this.activeTagNames.at(-1)
+ this.candidates = active ? [{ name: active, index: 0 }] : []
+ }
} else {
this.collect()
}
} else if (this.state === "TAG_OPEN") {
- if (char === ">" && this.index === this.tagName.length) {
- this.state = "TEXT"
- if (!this.matched) {
- this.cached = []
+ if (char === ">") {
+ const matched = this.candidates.find((c) => c.index === c.name.length)
+ if (matched) {
+ this.state = "TEXT"
+ this.activeTagNames.push(matched.name)
+ if (!this.matched) {
+ this.cached = []
+ }
+ this.depth++
+ this.matched = true
+ continue
}
- this.depth++
- this.matched = true
- } else if (this.index === 0 && char === "/") {
+ } else if (this.candidates.every((c) => c.index === 0) && char === "/") {
this.state = "TAG_CLOSE"
- } else if (char === " " && (this.index === 0 || this.index === this.tagName.length)) {
+ this.index = 0
continue
- } else if (this.tagName[this.index] === char) {
- this.index++
+ } else if (char === " ") {
+ const remaining = this.candidates.filter((c) => c.index === 0 || c.index === c.name.length)
+ if (remaining.length === this.candidates.length) {
+ continue
+ }
+ this.candidates = remaining
} else {
- this.state = "TEXT"
- this.collect()
+ this.candidates = this.candidates.filter((c) => c.name[c.index] === char)
+ for (const c of this.candidates) {
+ c.index++
+ }
+ if (this.candidates.length === 0) {
+ this.state = "TEXT"
+ this.collect()
+ }
}
} else if (this.state === "TAG_CLOSE") {
- if (char === ">" && this.index === this.tagName.length) {
+ const tagName = this.activeTagNames.at(-1) || this.tagNames[0]
+ if (char === ">" && this.index === tagName.length) {
this.state = "TEXT"
this.depth--
+ this.activeTagNames.pop()
this.matched = this.depth > 0
if (!this.matched) {
this.cached = []
}
- } else if (char === " " && (this.index === 0 || this.index === this.tagName.length)) {
+ } else if (char === " " && (this.index === 0 || this.index === tagName.length)) {
continue
- } else if (this.tagName[this.index] === char) {
+ } else if (tagName[this.index] === char) {
this.index++
} else {
this.state = "TEXT"
@@ -102,10 +134,15 @@ export class TagMatcher {
this._update(chunk)
}
this.collect()
+ this.candidates = []
+ this.activeTagNames = []
return this.pop()
}
update(chunk: string) {
this._update(chunk)
+ if (this.state === "TEXT") {
+ this.collect()
+ }
return this.pop()
}
}