From aa8cb2f0ad986ccc6d88626f815c705237b2713f Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sun, 3 Aug 2025 21:04:08 +0000 Subject: [PATCH] fix: parse XML thinking and tool_call blocks in OpenRouter responses - Add XML parsing for and blocks in OpenRouter handler - Handle incomplete XML blocks across streaming chunks - Convert tool_call blocks to user-friendly format - Add comprehensive tests for XML parsing functionality Fixes #6630 --- .../providers/__tests__/openrouter.spec.ts | 193 ++++++++++++++++++ src/api/providers/openrouter.ts | 67 +++++- 2 files changed, 259 insertions(+), 1 deletion(-) diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index ea850c47be..4b58854b42 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -265,6 +265,199 @@ describe("OpenRouterHandler", () => { const generator = handler.createMessage("test", []) await expect(generator.next()).rejects.toThrow("OpenRouter API Error 500: API Error") }) + + it("parses blocks correctly", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + id: "test-id", + choices: [{ delta: { content: "Before This is thinking content After" } }], + } + yield { + id: "test-id", + choices: [{ delta: {} }], + usage: { prompt_tokens: 10, completion_tokens: 20 }, + } + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(mockStream) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + const generator = handler.createMessage("test", []) + const chunks = [] + + for await (const chunk of generator) { + chunks.push(chunk) + } + + // Should have 3 text/reasoning chunks and 1 usage chunk + expect(chunks).toHaveLength(4) + expect(chunks[0]).toEqual({ type: "text", text: "Before " }) + expect(chunks[1]).toEqual({ type: "reasoning", text: "This is thinking content" }) + expect(chunks[2]).toEqual({ type: "text", text: " After" }) + expect(chunks[3]).toEqual({ + type: "usage", + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: undefined, + reasoningTokens: undefined, + totalCost: 0, + }) + }) + + it("parses blocks correctly", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + id: "test-id", + choices: [ + { delta: { content: "Text before Tool call content text after" } }, + ], + } + yield { + id: "test-id", + choices: [{ delta: {} }], + usage: { prompt_tokens: 10, completion_tokens: 20 }, + } + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(mockStream) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + const generator = handler.createMessage("test", []) + const chunks = [] + + for await (const chunk of generator) { + chunks.push(chunk) + } + + // Should have 3 text chunks (before, tool call formatted, after) and 1 usage chunk + expect(chunks).toHaveLength(4) + expect(chunks[0]).toEqual({ type: "text", text: "Text before " }) + expect(chunks[1]).toEqual({ type: "text", text: "[Tool Call]: Tool call content" }) + expect(chunks[2]).toEqual({ type: "text", text: " text after" }) + expect(chunks[3]).toEqual({ + type: "usage", + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: undefined, + reasoningTokens: undefined, + totalCost: 0, + }) + }) + + it("handles nested and multiple XML blocks", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + id: "test-id", + choices: [ + { + delta: { + content: "First think middle Tool usage", + }, + }, + ], + } + yield { + id: "test-id", + choices: [{ delta: { content: " Second think end" } }], + } + yield { + id: "test-id", + choices: [{ delta: {} }], + usage: { prompt_tokens: 10, completion_tokens: 20 }, + } + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(mockStream) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + const generator = handler.createMessage("test", []) + const chunks = [] + + for await (const chunk of generator) { + chunks.push(chunk) + } + + // Verify all chunks are parsed correctly + expect(chunks).toContainEqual({ type: "reasoning", text: "First think" }) + expect(chunks).toContainEqual({ type: "text", text: " middle " }) + expect(chunks).toContainEqual({ type: "text", text: "[Tool Call]: Tool usage" }) + expect(chunks).toContainEqual({ type: "text", text: " " }) + expect(chunks).toContainEqual({ type: "reasoning", text: "Second think" }) + expect(chunks).toContainEqual({ type: "text", text: " end" }) + expect(chunks).toContainEqual({ + type: "usage", + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: undefined, + reasoningTokens: undefined, + totalCost: 0, + }) + }) + + it("handles incomplete XML blocks across chunks", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + id: "test-id", + choices: [{ delta: { content: "Start Thinking content End" } }], + } + yield { + id: "test-id", + choices: [{ delta: {} }], + usage: { prompt_tokens: 10, completion_tokens: 20 }, + } + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(mockStream) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + const generator = handler.createMessage("test", []) + const chunks = [] + + for await (const chunk of generator) { + chunks.push(chunk) + } + + // Should correctly parse the thinking block even when split across chunks + expect(chunks).toContainEqual({ type: "text", text: "Start " }) + expect(chunks).toContainEqual({ type: "reasoning", text: "Thinking content" }) + expect(chunks).toContainEqual({ type: "text", text: " End" }) + expect(chunks).toContainEqual({ + type: "usage", + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: undefined, + reasoningTokens: undefined, + totalCost: 0, + }) + }) }) describe("completePrompt", () => { diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 6565daa238..5dc804b41e 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -18,6 +18,7 @@ import { addCacheBreakpoints as addAnthropicCacheBreakpoints } from "../transfor import { addCacheBreakpoints as addGeminiCacheBreakpoints } from "../transform/caching/gemini" import type { OpenRouterReasoningParams } from "../transform/reasoning" import { getModelParams } from "../transform/model-params" +import { XmlMatcher } from "../../utils/xml-matcher" import { getModels } from "./fetchers/modelCache" import { getModelEndpoints } from "./fetchers/modelEndpointCache" @@ -137,6 +138,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const stream = await this.client.chat.completions.create(completionParams) let lastUsage: CompletionUsage | undefined = undefined + let buffer = "" for await (const chunk of stream) { // OpenRouter returns an error object instead of the OpenAI SDK throwing an error. @@ -153,7 +155,65 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } if (delta?.content) { - yield { type: "text", text: delta.content } + buffer += delta.content + + // Process complete XML blocks + let processed = true + while (processed) { + processed = false + + // Check for complete blocks + const thinkMatch = buffer.match(/^(.*?)([\s\S]*?)<\/think>(.*)$/s) + if (thinkMatch) { + const [, before, content, after] = thinkMatch + if (before) { + yield { type: "text", text: before } + } + yield { type: "reasoning", text: content } + buffer = after + processed = true + continue + } + + // Check for complete blocks + const toolMatch = buffer.match(/^(.*?)([\s\S]*?)<\/tool_call>(.*)$/s) + if (toolMatch) { + const [, before, content, after] = toolMatch + if (before) { + yield { type: "text", text: before } + } + yield { type: "text", text: `[Tool Call]: ${content}` } + buffer = after + processed = true + continue + } + + // Check if we have an incomplete tag at the end + const incompleteTag = buffer.match(/^(.*?)(<(?:think|tool_call)[^>]*(?:>[\s\S]*)?)?$/s) + if (incompleteTag && incompleteTag[2]) { + // We have an incomplete tag, yield the text before it and keep the tag in buffer + const [, before, tag] = incompleteTag + if (before) { + yield { type: "text", text: before } + buffer = tag + } + break + } + + // No tags found or incomplete, yield all content except potential start of a tag + const tagStart = buffer.lastIndexOf("<") + if (tagStart === -1) { + // No < found, yield all + if (buffer) { + yield { type: "text", text: buffer } + buffer = "" + } + } else if (tagStart > 0) { + // Yield content before the < + yield { type: "text", text: buffer.substring(0, tagStart) } + buffer = buffer.substring(tagStart) + } + } } if (chunk.usage) { @@ -161,6 +221,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } } + // Process any remaining content in the buffer + if (buffer) { + yield { type: "text", text: buffer } + } + if (lastUsage) { yield { type: "usage",