fix: Add reasoning chunk support for OpenAI Compatible provider

- Handle both 'reasoning' and 'reasoning_content' fields in streaming responses
- Support custom OpenAI-compatible providers that return reasoning data
- Add comprehensive tests for reasoning chunk handling
- Fixes #8782: Reasoning chunks not displayed in OpenAI Compatible provider
This commit is contained in:
Roo Code 2025-10-22 22:09:19 +00:00
parent 8187a8e189
commit 56d5bd6912
2 changed files with 458 additions and 0 deletions

View file

@ -0,0 +1,443 @@
// npx vitest run src/api/providers/__tests__/base-openai-compatible-provider.spec.ts
import { describe, it, expect, beforeEach, vi } from "vitest"
import OpenAI from "openai"
import { Anthropic } from "@anthropic-ai/sdk"
import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider"
import type { ModelInfo } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../../shared/api"
// Mock OpenAI
const mockCreate = vi.fn()
vi.mock("openai", () => {
return {
default: vi.fn(() => ({ chat: { completions: { create: mockCreate } } })),
}
})
// Create a concrete test implementation of the abstract class
class TestProvider extends BaseOpenAiCompatibleProvider<"test-model"> {
constructor(options: ApiHandlerOptions) {
super({
providerName: "TestProvider",
baseURL: "https://test.api.com/v1",
defaultProviderModelId: "test-model",
providerModels: {
"test-model": {
contextWindow: 128000,
maxTokens: 4096,
supportsImages: true,
supportsPromptCache: false,
} as ModelInfo,
},
apiKey: options.apiKey || "test-key",
...options,
})
}
}
describe("BaseOpenAiCompatibleProvider", () => {
let provider: TestProvider
beforeEach(() => {
vi.clearAllMocks()
provider = new TestProvider({ apiKey: "test-api-key" })
})
describe("reasoning chunk handling", () => {
it("should yield reasoning chunks when delta contains reasoning_content field", async () => {
const reasoningText = "Let me think about this step by step..."
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
choices: [
{
delta: {
content: "Here is my response",
reasoning_content: reasoningText,
},
},
],
},
})
.mockResolvedValueOnce({ done: true }),
}),
}
})
const stream = provider.createMessage("system prompt", [])
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Should have both text and reasoning chunks
expect(chunks).toHaveLength(2)
const textChunk = chunks.find((c) => c.type === "text")
expect(textChunk).toEqual({
type: "text",
text: "Here is my response",
})
const reasoningChunk = chunks.find((c) => c.type === "reasoning")
expect(reasoningChunk).toEqual({
type: "reasoning",
text: reasoningText,
})
})
it("should yield reasoning chunks when delta contains reasoning field", async () => {
const reasoningText = "Analyzing the problem..."
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
choices: [
{
delta: {
content: "The answer is 42",
reasoning: reasoningText,
},
},
],
},
})
.mockResolvedValueOnce({ done: true }),
}),
}
})
const stream = provider.createMessage("system prompt", [])
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Should have both text and reasoning chunks
expect(chunks).toHaveLength(2)
const textChunk = chunks.find((c) => c.type === "text")
expect(textChunk).toEqual({
type: "text",
text: "The answer is 42",
})
const reasoningChunk = chunks.find((c) => c.type === "reasoning")
expect(reasoningChunk).toEqual({
type: "reasoning",
text: reasoningText,
})
})
it("should prefer reasoning_content over reasoning when both are present", async () => {
const reasoningContentText = "This should be used"
const reasoningText = "This should not be used"
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
choices: [
{
delta: {
content: "Response text",
reasoning_content: reasoningContentText,
reasoning: reasoningText,
},
},
],
},
})
.mockResolvedValueOnce({ done: true }),
}),
}
})
const stream = provider.createMessage("system prompt", [])
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const reasoningChunk = chunks.find((c) => c.type === "reasoning")
expect(reasoningChunk).toEqual({
type: "reasoning",
text: reasoningContentText,
})
})
it("should not yield reasoning chunk for empty reasoning content", async () => {
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
choices: [
{
delta: {
content: "Response",
reasoning: "",
},
},
],
},
})
.mockResolvedValueOnce({ done: true }),
}),
}
})
const stream = provider.createMessage("system prompt", [])
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Should only have text chunk, no reasoning chunk for empty string
expect(chunks).toHaveLength(1)
expect(chunks[0].type).toBe("text")
const reasoningChunk = chunks.find((c) => c.type === "reasoning")
expect(reasoningChunk).toBeUndefined()
})
it("should handle null or undefined reasoning fields", async () => {
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
choices: [
{
delta: {
content: "Response",
reasoning: null,
reasoning_content: undefined,
},
},
],
},
})
.mockResolvedValueOnce({ done: true }),
}),
}
})
const stream = provider.createMessage("system prompt", [])
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Should only have text chunk, no reasoning chunk
expect(chunks).toHaveLength(1)
expect(chunks[0].type).toBe("text")
})
it("should handle multiple reasoning chunks in stream", async () => {
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
choices: [
{
delta: {
reasoning_content: "First reasoning part...",
},
},
],
},
})
.mockResolvedValueOnce({
done: false,
value: {
choices: [
{
delta: {
content: "Some text",
},
},
],
},
})
.mockResolvedValueOnce({
done: false,
value: {
choices: [
{
delta: {
reasoning: "Second reasoning part...",
},
},
],
},
})
.mockResolvedValueOnce({ done: true }),
}),
}
})
const stream = provider.createMessage("system prompt", [])
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks).toHaveLength(3)
const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
expect(reasoningChunks).toHaveLength(2)
expect(reasoningChunks[0].text).toBe("First reasoning part...")
expect(reasoningChunks[1].text).toBe("Second reasoning part...")
const textChunks = chunks.filter((c) => c.type === "text")
expect(textChunks).toHaveLength(1)
expect(textChunks[0].text).toBe("Some text")
})
it("should handle reasoning chunks with usage data", async () => {
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
choices: [
{
delta: {
reasoning_content: "Reasoning...",
},
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 20,
},
},
})
.mockResolvedValueOnce({ done: true }),
}),
}
})
const stream = provider.createMessage("system prompt", [])
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks).toHaveLength(2)
const reasoningChunk = chunks.find((c) => c.type === "reasoning")
expect(reasoningChunk).toBeDefined()
const usageChunk = chunks.find((c) => c.type === "usage")
expect(usageChunk).toEqual({
type: "usage",
inputTokens: 10,
outputTokens: 20,
})
})
})
describe("backward compatibility", () => {
it("should work normally when no reasoning fields are present", async () => {
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
choices: [
{
delta: {
content: "Regular response without reasoning",
},
},
],
},
})
.mockResolvedValueOnce({
done: false,
value: {
choices: [
{
delta: {},
},
],
usage: {
prompt_tokens: 15,
completion_tokens: 25,
},
},
})
.mockResolvedValueOnce({ done: true }),
}),
}
})
const stream = provider.createMessage("system prompt", [])
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks).toHaveLength(2)
const textChunk = chunks.find((c) => c.type === "text")
expect(textChunk).toEqual({
type: "text",
text: "Regular response without reasoning",
})
const usageChunk = chunks.find((c) => c.type === "usage")
expect(usageChunk).toEqual({
type: "usage",
inputTokens: 15,
outputTokens: 25,
})
// No reasoning chunks
const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
expect(reasoningChunks).toHaveLength(0)
})
})
})

View file

@ -109,6 +109,21 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
}
}
// Handle reasoning chunks - support both 'reasoning' and 'reasoning_content' fields
// Some providers use 'reasoning_content' (like OpenAI o1/o3 models)
// Others use 'reasoning' (like custom OpenAI-compatible providers)
if ("reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
text: (delta.reasoning_content as string | undefined) || "",
}
} else if ("reasoning" in delta && delta.reasoning) {
yield {
type: "reasoning",
text: (delta.reasoning as string | undefined) || "",
}
}
if (chunk.usage) {
yield {
type: "usage",