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() } }