From bfabe5da761a1b65ddfb0b025e264966bf01702b Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 11 Nov 2025 18:57:22 +0000 Subject: [PATCH] fix: strip XML tags from kimi-k2-thinking model reasoning output - Added stripXmlTags method to remove XML tags from reasoning content - Applied XML tag stripping specifically for moonshot/kimi-k2-thinking model - Added tests to verify XML tags are stripped for kimi model but preserved for others - Fixes #9172 where XML tags were displayed in thinking prompts --- .../providers/__tests__/openrouter.spec.ts | 103 ++++++++++++++++++ src/api/providers/openrouter.ts | 28 ++++- 2 files changed, 125 insertions(+), 6 deletions(-) diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index f5067ef34c..5a6111738d 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -130,6 +130,109 @@ describe("OpenRouterHandler", () => { }) describe("createMessage", () => { + it("strips XML tags from reasoning content for kimi-k2-thinking model", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterModelId: "moonshot/kimi-k2-thinking", + }) + + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + choices: [ + { + delta: { + reasoning: + "This is reasoning content with XML tags", + }, + }, + ], + } + yield { + choices: [ + { + delta: { + content: "Regular content", + }, + }, + ], + } + yield { + usage: { + prompt_tokens: 10, + completion_tokens: 20, + }, + } + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(mockStream) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + const chunks = [] + const generator = handler.createMessage("test", []) + for await (const chunk of generator) { + chunks.push(chunk) + } + + // Check that reasoning content has XML tags stripped + expect(chunks[0]).toEqual({ + type: "reasoning", + text: "This is reasoning content with XML tags", + }) + expect(chunks[1]).toEqual({ + type: "text", + text: "Regular content", + }) + }) + + it("does not strip XML tags from reasoning content for other models", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterModelId: "openai/gpt-4", + }) + + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + choices: [ + { + delta: { + reasoning: + "This is reasoning content with XML tags", + }, + }, + ], + } + yield { + usage: { + prompt_tokens: 10, + completion_tokens: 20, + }, + } + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(mockStream) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + const chunks = [] + const generator = handler.createMessage("test", []) + for await (const chunk of generator) { + chunks.push(chunk) + } + + // Check that reasoning content keeps XML tags for non-kimi models + expect(chunks[0]).toEqual({ + type: "reasoning", + text: "This is reasoning content with XML tags", + }) + }) + it("generates correct stream chunks", async () => { const handler = new OpenRouterHandler(mockOptions) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index d16a410b13..871cdec3cc 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -98,6 +98,15 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: DEFAULT_HEADERS }) } + /** + * Strip XML tags from reasoning content for models that include them + * Some models like kimi-k2-thinking include XML tags in their reasoning output + */ + private stripXmlTags(text: string): string { + // Remove XML tags but preserve the content between them + return text.replace(/<[^>]*>/g, "") + } + override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], @@ -180,14 +189,21 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH throw new Error(`OpenRouter API Error ${error?.code}: ${error?.message}`) } - const delta = chunk.choices[0]?.delta + const delta = chunk.choices?.[0]?.delta - if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { - yield { type: "reasoning", text: delta.reasoning } - } + if (delta) { + const modelId = this.options.openRouterModelId ?? openRouterDefaultModelId - if (delta?.content) { - yield { type: "text", text: delta.content } + if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { + // Strip XML tags for kimi-k2-thinking model which includes them in reasoning output + const reasoningText = + modelId === "moonshot/kimi-k2-thinking" ? this.stripXmlTags(delta.reasoning) : delta.reasoning + yield { type: "reasoning", text: reasoningText } + } + + if (delta.content) { + yield { type: "text", text: delta.content } + } } if (chunk.usage) {