Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions src/api/providers/__tests__/openai.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,190 @@ describe("OpenAiHandler", () => {
expect(callArgs.reasoning_effort).toBeUndefined()
})

it("should yield reasoning chunks from streaming response with reasoning_content field", async () => {
mockCreate.mockImplementationOnce(async () => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [{ delta: { reasoning_content: "Step 1: Think about it" }, index: 0 }],
usage: null,
}
yield {
choices: [{ delta: { content: "Final answer" }, index: 0 }],
usage: null,
}
yield {
choices: [{ delta: {}, index: 0 }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
}
},
}))

const stream = handler.createMessage(systemPrompt, messages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}

const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
expect(reasoningChunks).toHaveLength(1)
expect(reasoningChunks[0]).toEqual({ type: "reasoning", text: "Step 1: Think about it" })

const textChunks = chunks.filter((c) => c.type === "text")
expect(textChunks.some((c) => c.text === "Final answer")).toBe(true)
})

it("should yield reasoning chunks from streaming response with reasoning field", async () => {
mockCreate.mockImplementationOnce(async () => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [{ delta: { reasoning: "Step 1: Think about it via reasoning field" }, index: 0 }],
usage: null,
}
yield {
choices: [{ delta: { content: "Final answer" }, index: 0 }],
usage: null,
}
yield {
choices: [{ delta: {}, index: 0 }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
}
},
}))

const stream = handler.createMessage(systemPrompt, messages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}

const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
expect(reasoningChunks).toHaveLength(1)
expect(reasoningChunks[0]).toEqual({
type: "reasoning",
text: "Step 1: Think about it via reasoning field",
})
})

it("should prefer reasoning_content over reasoning when both are present in streaming delta", async () => {
mockCreate.mockImplementationOnce(async () => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: { reasoning_content: "From reasoning_content", reasoning: "From reasoning" },
index: 0,
},
],
usage: null,
}
yield {
choices: [{ delta: {}, index: 0 }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
}
},
}))

const stream = handler.createMessage(systemPrompt, messages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}

const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
expect(reasoningChunks).toHaveLength(1)
expect(reasoningChunks[0]).toEqual({ type: "reasoning", text: "From reasoning_content" })
})

it("should not yield reasoning chunk for empty or whitespace-only reasoning in streaming", async () => {
mockCreate.mockImplementationOnce(async () => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [{ delta: { reasoning_content: " " }, index: 0 }],
usage: null,
}
yield {
choices: [{ delta: { content: "Answer" }, index: 0 }],
usage: null,
}
yield {
choices: [{ delta: {}, index: 0 }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
}
},
}))

const stream = handler.createMessage(systemPrompt, messages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}

const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
expect(reasoningChunks).toHaveLength(0)
})

it("should yield reasoning from non-streaming response with reasoning_content field", async () => {
mockCreate.mockImplementationOnce(async () => ({
choices: [
{
message: {
role: "assistant",
content: "Final answer",
reasoning_content: "Non-streaming reasoning",
},
finish_reason: "stop",
index: 0,
},
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
}))

const nonStreamHandler = new OpenAiHandler({ ...mockOptions, openAiStreamingEnabled: false })
const stream = nonStreamHandler.createMessage(systemPrompt, messages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}

const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
expect(reasoningChunks).toHaveLength(1)
expect(reasoningChunks[0]).toEqual({ type: "reasoning", text: "Non-streaming reasoning" })

const textChunks = chunks.filter((c) => c.type === "text")
expect(textChunks.some((c) => c.text === "Final answer")).toBe(true)
})

it("should yield reasoning from non-streaming response with reasoning field", async () => {
mockCreate.mockImplementationOnce(async () => ({
choices: [
{
message: {
role: "assistant",
content: "Final answer",
reasoning: "Non-streaming reasoning via reasoning field",
},
finish_reason: "stop",
index: 0,
},
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
}))

const nonStreamHandler = new OpenAiHandler({ ...mockOptions, openAiStreamingEnabled: false })
const stream = nonStreamHandler.createMessage(systemPrompt, messages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}

const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
expect(reasoningChunks).toHaveLength(1)
expect(reasoningChunks[0]).toEqual({
type: "reasoning",
text: "Non-streaming reasoning via reasoning field",
})
})

it("should include max_tokens when includeMaxTokens is true", async () => {
const optionsWithMaxTokens: ApiHandlerOptions = {
...mockOptions,
Expand Down
18 changes: 14 additions & 4 deletions src/api/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
}
}

if ("reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
text: (delta.reasoning_content as string | undefined) || "",
for (const key of ["reasoning_content", "reasoning"] as const) {
if (key in delta) {
const reasoning = ((delta as any)[key] as string | undefined) || ""
if (reasoning?.trim()) {
yield { type: "reasoning", text: reasoning }
}
break
}
}

Expand Down Expand Up @@ -260,6 +263,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
}
}

for (const key of ["reasoning_content", "reasoning"] as const) {
if (message && key in message && (message as any)[key]) {
yield { type: "reasoning", text: (message as any)[key] as string }
break
}
}
Comment on lines +266 to +271
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The non-streaming path doesn't apply the same whitespace-trimming filter as the streaming path (line 205). A whitespace-only string like " " is truthy, so it would be yielded as a reasoning chunk here but filtered out in streaming mode. This is unlikely to cause real problems since providers rarely return whitespace-only reasoning in complete responses, but it's an inconsistency that could be worth aligning.

Suggested change
for (const key of ["reasoning_content", "reasoning"] as const) {
if (message && key in message && (message as any)[key]) {
yield { type: "reasoning", text: (message as any)[key] as string }
break
}
}
for (const key of ["reasoning_content", "reasoning"] as const) {
if (message && key in message && (message as any)[key]) {
const reasoning = (message as any)[key] as string
if (reasoning.trim()) {
yield { type: "reasoning", text: reasoning }
}
break
}
}

Fix it with Roo Code or mention @roomote and request a fix.


yield {
type: "text",
text: message?.content || "",
Expand Down
Loading